From 5e4dc15caf1a49635b8aa47898904ecc5d256f56 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:11:16 +0200 Subject: [PATCH 01/20] FIX shortcode_close_parent closes the shortcode's own wrapping element (#8) --- CHANGELOG.md | 14 ++ README.md | 3 + src/Extensions/ShortcodeParserExtension.php | 232 ++++++++++++++++--- tests/ShortcodeParserExtensionTest.php | 139 +++++++++++ tests/browser/fixtures/ScbBlockShortcode.php | 34 +++ tests/browser/fixtures/ScbResetAdmin.php | 25 +- tests/browser/specs/close-parent.spec.ts | 37 +++ 7 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 tests/browser/fixtures/ScbBlockShortcode.php create mode 100644 tests/browser/specs/close-parent.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 522d3a4..f614253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 5.1.1 (unreleased) + +### 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. + ## 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..27752e4 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ 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. ## Shortcode placeholder images diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 81e77b0..7906f82 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -28,34 +28,208 @@ 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); - }, - (string) $content - ); + # 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', + ]; + + /** + * 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. 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)); + # 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 '/', so [sc_block_other] is not [sc_block]. + # Enclosed content (`[sc]...[/sc]`) is matched as in the original regex: no '[' inside it. + $pattern = '%(?)' + . '|(?[a-z][a-z0-9]*)\b[^>]*>)' + . '|(?\[(?' . $tagAlternation . ')(?=[\s\]/])[^\]]*\](?:[^\[]*?\[/\k\])*)%is'; + if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { + return $content; + } + + # Tokenise: text between the matches, plus each match with its kind + $tokens = []; + $cursor = 0; + foreach ($matches as $match) { + [$string, $offset] = $match[0]; + if ($offset > $cursor) { + $tokens[] = ['text', substr($content, $cursor, $offset - $cursor), null]; + } + if (isset($match['sc']) && $match['sc'][1] !== -1) { + $tokens[] = ['sc', $string, null]; + } elseif (isset($match['tag']) && $match['tag'][1] !== -1) { + $tokens[] = ['tag', $string, strtolower($match['name'][0])]; + } else { + $tokens[] = ['text', $string, null]; + } + $cursor = $offset + strlen($string); + } + if ($cursor < strlen($content)) { + $tokens[] = ['text', substr($content, $cursor), null]; + } + + $out = ''; + # Open elements, outermost first: [name, original opening tag] + $stack = []; + $count = count($tokens); + for ($i = 0; $i < $count; $i++) { + [$kind, $string, $name] = $tokens[$i]; + + if ($kind === 'tag') { + $out .= $string; + $this->trackTag($stack, $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: inline ancestors up to the first block one + $chain = []; + for ($s = count($stack) - 1; $s >= 0; $s--) { + $chain[] = $stack[$s]; + if (!in_array($stack[$s][0], self::INLINE_ELEMENTS)) { + break; + } + } + + #

[sc] ->

[sc] (a line break right before block output only adds a blank line) + $out = preg_replace('%\s*$%i', '', $out); + 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. + $trimmed = rtrim($out); + if (preg_match('%^<[a-z0-9]+\\s*>$%i', $openTag) && substr($trimmed, -strlen($openTag)) === $openTag) { + $out = substr($trimmed, 0, -strlen($openTag)); + } 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 && $tokens[$next][0] === 'text' && trim($tokens[$next][1]) === '') { + $next++; + } + if ($next < $count && $tokens[$next][0] === 'tag' && $tokens[$next][2] === $chain[0][0] + && strpos($tokens[$next][1], 'trackTag($stack, $tokens[$next][1], $tokens[$next][2]); + 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. + foreach (array_reverse($chain) as [$elName, $openTag]) { + $out .= in_array($elName, self::INLINE_ELEMENTS) ? $openTag : "<{$elName}>"; + } + } + + return $out; + } + + /** + * Update the open-element stack for one tag. + * + * @param array $stack open elements, outermost first: [name, original opening tag] + * @param string $tag the tag as written + * @param string $name its lowercased element name + */ + private function trackTag(array &$stack, string $tag, string $name): void + { + if (strpos($tag, '= 0; $s--) { + if ($stack[$s][0] === $name) { + array_splice($stack, $s); + break; + } + } + return; + } + if (in_array($name, self::VOID_ELEMENTS) || substr(rtrim($tag, '> '), -1) === '/') { + # Void or self-closed: never open + return; + } + $stack[] = [$name, $tag]; } } diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index e2b32e2..2949006 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,144 @@ 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", + ], + '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

", + ], + // 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)); + } + public function testParserInstanceWithoutTheShortcodeRegisteredIsLeftAlone() { // A separate parser instance (like the 'regenerator' one) that does not know sc_block diff --git a/tests/browser/fixtures/ScbBlockShortcode.php b/tests/browser/fixtures/ScbBlockShortcode.php new file mode 100644 index 0000000..b0d5908 --- /dev/null +++ b/tests/browser/fixtures/ScbBlockShortcode.php @@ -0,0 +1,34 @@ +BLOCK
'; + } + + public function getShortcodeLabel(): string + { + return 'Browser block'; + } +} diff --git a/tests/browser/fixtures/ScbResetAdmin.php b/tests/browser/fixtures/ScbResetAdmin.php index 8ca231c..193b0f1 100644 --- a/tests/browser/fixtures/ScbResetAdmin.php +++ b/tests/browser/fixtures/ScbResetAdmin.php @@ -3,15 +3,20 @@ namespace Restruct\ScbBrowser; use Page; +use Shortcodable\Shortcodable; use SilverStripe\Admin\LeftAndMain; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Core\Config\Config; +use SilverStripe\View\Parsers\ShortcodeParser; /** * BROWSER-TEST FIXTURE ONLY - lets a spec start from a known page and read back what was saved: * GET /admin/scb-reset/page?title=...&content=... creates a draft Page with that Content and * answers {"id": page ID, "records": {title: ID}} (the two ScbRecords, created once); - * GET /admin/scb-reset/saved?id=N answers {"content": the draft Content as stored}. + * GET /admin/scb-reset/saved?id=N answers {"content": the draft Content as stored}; + * GET /admin/scb-reset/parsed?content=... answers an HTML page whose body is that content run + * through the shortcode parser, as a template renders HTMLText (for the DOM-structure specs). * * A LeftAndMain because the admin routes those by url_segment with no YAML; LeftAndMain's own * access check applies, so only the logged-in admin can call it. See ScbYearShortcode for why this @@ -23,7 +28,7 @@ class ScbResetAdmin extends LeftAndMain private static $menu_title = 'Shortcodable browser reset'; - private static $allowed_actions = ['page', 'saved']; + private static $allowed_actions = ['page', 'saved', 'parsed']; public function page(HTTPRequest $request): HTTPResponse { @@ -55,6 +60,22 @@ public function saved(HTTPRequest $request): HTTPResponse return $this->json(['content' => (string) $page->Content]); } + public function parsed(HTTPRequest $request): HTTPResponse + { + # The block fixture is registered for this request only, not in fixtures/_config: registered + # there it would also be listed in the dialog's shortcode type dropdown, which the dialog specs + # check option by option. + Config::modify()->merge(Shortcodable::class, 'shortcodable_classes', [ScbBlockShortcode::class]); + Shortcodable::register_class(ScbBlockShortcode::class); + + $body = ShortcodeParser::get_active()->parse((string) $request->getVar('content')); + # A bare document: no CMS assets, and an empty data: favicon so the browser's automatic + # /favicon.ico request cannot log a 404 console error. + $html = 'render' + . '' . $body . ''; + return HTTPResponse::create($html)->addHeader('Content-Type', 'text/html; charset=utf-8'); + } + private function json(array $data): HTTPResponse { return HTTPResponse::create(json_encode($data))->addHeader('Content-Type', 'application/json'); diff --git a/tests/browser/specs/close-parent.spec.ts b/tests/browser/specs/close-parent.spec.ts new file mode 100644 index 0000000..578c868 --- /dev/null +++ b/tests/browser/specs/close-parent.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from './support'; + +// Issue #8: a shortcode with $shortcode_close_parent must close the element that actually wraps it. +// The content is rendered through the shortcode parser (/admin/scb-reset/render) and the DOM the +// browser builds from that response is compared with the DOM of the expected markup. Wrong +// closing tags do not show as a string difference here but as a different tree: the browser +// repairs `

` into a stray empty

, and leaves block output inside an unclosed

. +// The exhaustive case table is ShortcodeParserExtensionTest; these are the structural cases. + +const B = '

BLOCK
'; + +const cases: [string, string, string][] = [ + ['heading and paragraph before (the issue)', '

Address

Street 1

[scb_block]

', `

Address

Street 1

${B}`], + ['heading before, text around', '

Title

Some [scb_block] content

', `

Title

Some

${B}

content

`], + ['nested in a div', '

Some [scb_block] content

', `

Some

${B}

content

`], + ['inside strong inside p', '

Some bold [scb_block] text end

', `

Some bold

${B}

text end

`], + ['two in one paragraph', '

A [scb_block] B [scb_block] C

', `

A

${B}

B

${B}

C

`], + ['hr sibling before', '

[scb_block]

', `
${B}`], + ['top level, between paragraphs', '

Intro

[scb_block]

Outro

', `

Intro

${B}

Outro

`], + ['inline shortcode stays inline', '

Year

It is [scb_year] now

', '

Year

It is 2026 now

'], +]; + +test.describe('shortcode_close_parent output structure', () => { + for (const [label, content, expected] of cases) { + test(label, async ({ page }) => { + const response = await page.goto(`/admin/scb-reset/parsed?content=${encodeURIComponent(content)}`); + expect(response?.status()).toBe(200); + // Serialise both trees the same way: the live document's body, and the expected markup + // parsed by the same browser. + const [actualTree, expectedTree] = await page.evaluate((exp) => { + const parsed = new DOMParser().parseFromString(`${exp}`, 'text/html'); + return [document.body.innerHTML, parsed.body.innerHTML]; + }, expected); + expect(actualTree).toBe(expectedTree); + }); + } +}); From 0f8f106ae445bb395fc4637d6dc3b3cf0de7d50a Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:27:04 +0200 Subject: [PATCH 02/20] TEST container parents of a block shortcode, pending decision in #9 --- CHANGELOG.md | 5 +++++ tests/ShortcodeParserExtensionTest.php | 27 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f614253..4bc0dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ `[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 parent on a line of its own + (`

\n[sc]\n

`) was left alone; it is now closed as well. For containers that can already hold + block content (``, `
  • `, `
    `, `
    `) and for `
    `, this means they are now
    +  closed and split too, where 5.1.0 left multi-line forms untouched. Whether they should be is open
    +  in #9.
     
     ## 5.1.0 (2026-09-25)
     
    diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php
    index 2949006..90df6e5 100644
    --- a/tests/ShortcodeParserExtensionTest.php
    +++ b/tests/ShortcodeParserExtensionTest.php
    @@ -200,6 +200,33 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement()
             $this->assertSame([], $failures, count($failures) . ' of ' . count($cases) . " cases failed:\n" . implode("\n", $failures));
         }
     
    +    /**
    +     * Issue #9 (open, waiting on a decision): which elements a block shortcode may close when its
    +     * parent is a container that can already hold block content (td, li, blockquote, section) or
    +     * pre. 5.1.0 only looked within one line, so these multi-line forms were left alone; the
    +     * tag walk now closes/splits the container. No behaviour is asserted here on purpose: the
    +     * inputs are kept so the #9 decision has its cases ready. Each one is parsed so the rows at
    +     * least run, and the test is reported as incomplete.
    +     */
    +    public function testContainerParentsAwaitIssue9()
    +    {
    +        $cases = [
    +            'table cell, multi-line' => "\n\n
    \n[sc_block]\n
    ", + 'list item, multi-line' => "
      \n
    • \n[sc_block]\n
    • \n
    ", + 'blockquote, multi-line' => "
    \n[sc_block]\n
    ", + 'section, multi-line' => "
    \n[sc_block]\n
    ", + 'pre, multi-line' => "
    \nline\n[sc_block]\n
    ", + 'table cell, one line' => '
    [sc_block]
    ', + 'list item, one line' => '
    • One
    • [sc_block]
    ', + 'div, one line' => '
    [sc_block]
    ', + ]; + $report = []; + foreach ($cases as $label => $input) { + $report[] = sprintf("%s\n input: %s\n output: %s", $label, json_encode($input), json_encode(ShortcodeParser::get()->parse($input))); + } + $this->markTestIncomplete("Container parents: behaviour to be decided in issue #9, not asserted.\n" . implode("\n", $report)); + } + public function testParserInstanceWithoutTheShortcodeRegisteredIsLeftAlone() { // A separate parser instance (like the 'regenerator' one) that does not know sc_block From c16248dc43ab64804760db7e35110687f3b27437 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:27:32 +0200 Subject: [PATCH 03/20] FIX block shortcode with comma-separated attributes not recognised (#8) --- src/Extensions/ShortcodeParserExtension.php | 5 +++-- tests/ShortcodeParserExtensionTest.php | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 7906f82..91868d1 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -106,11 +106,12 @@ private function closeParentElements(string $content, array $tags): string }, $tags)); # 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 '/', so [sc_block_other] is not [sc_block]. + # 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. $pattern = '%(?)' . '|(?[a-z][a-z0-9]*)\b[^>]*>)' - . '|(?\[(?' . $tagAlternation . ')(?=[\s\]/])[^\]]*\](?:[^\[]*?\[/\k\])*)%is'; + . '|(?\[(?' . $tagAlternation . ')(?=[\s\],/])[^\]]*\](?:[^\[]*?\[/\k\])*)%is'; if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { return $content; } diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 90df6e5..5ea8d4b 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -182,6 +182,11 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() '

    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 tag that merely starts with the block tag's name is another shortcode 'other shortcode sharing the prefix' => [ '

    Some [sc_block_other] x

    ', From b32264e0c27dbee64833e8cbdb48a834b0c9efd1 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:28:14 +0200 Subject: [PATCH 04/20] FIX hyphenated custom element names cut at the hyphen (#8) --- src/Extensions/ShortcodeParserExtension.php | 17 ++++++++++++++--- tests/ShortcodeParserExtensionTest.php | 6 ++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 91868d1..ace9cb1 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -109,8 +109,10 @@ private function closeParentElements(string $content, array $tags): string # 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. $pattern = '%(?)' - . '|(?[a-z][a-z0-9]*)\b[^>]*>)' + . '|(?[a-z][a-z0-9-]*)(?=[\s/>])[^>]*>)' . '|(?\[(?' . $tagAlternation . ')(?=[\s\],/])[^\]]*\](?:[^\[]*?\[/\k\])*)%is'; if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { return $content; @@ -159,7 +161,7 @@ private function closeParentElements(string $content, array $tags): string $chain = []; for ($s = count($stack) - 1; $s >= 0; $s--) { $chain[] = $stack[$s]; - if (!in_array($stack[$s][0], self::INLINE_ELEMENTS)) { + if (!$this->isInline($stack[$s][0])) { break; } } @@ -201,13 +203,22 @@ private function closeParentElements(string $content, array $tags): string # before (an id must not be duplicated); inline ones keep their attributes, so a link or # a styled span carries on after the shortcode. foreach (array_reverse($chain) as [$elName, $openTag]) { - $out .= in_array($elName, self::INLINE_ELEMENTS) ? $openTag : "<{$elName}>"; + $out .= $this->isInline($elName) ? $openTag : "<{$elName}>"; } } return $out; } + /** + * 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. * diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 5ea8d4b..9045932 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -187,6 +187,12 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() '

    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 tag that merely starts with the block tag's name is another shortcode 'other shortcode sharing the prefix' => [ '

    Some [sc_block_other] x

    ', From d9131b2b91a1da9f5e030755aa3405cd00a580e9 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:28:48 +0200 Subject: [PATCH 05/20] FIX reopened inline element repeats its id or name anchor (#8) --- src/Extensions/ShortcodeParserExtension.php | 16 ++++++++++++++-- tests/ShortcodeParserExtensionTest.php | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index ace9cb1..91bcf94 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -201,15 +201,27 @@ private function closeParentElements(string $content, array $tags): string # 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. + # a styled span carries on after the shortcode - minus their id (and a link's name + # anchor), which must stay unique. foreach (array_reverse($chain) as [$elName, $openTag]) { - $out .= $this->isInline($elName) ? $openTag : "<{$elName}>"; + $out .= $this->isInline($elName) ? $this->withoutUniqueAttributes($openTag, $elName) : "<{$elName}>"; } } return $out; } + /** + * 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'; + # Whitespace before the name, so data-id or aria-name are left alone; quoted or bare values + return preg_replace('%\s+(?:' . $attributes . ')\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>"\']+)%i', '', $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. diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 9045932..41ebcbf 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -193,6 +193,15 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() '

    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

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

    Some [sc_block_other] x

    ', From 1def126b7ab7413a7a17ec223e4e4d3e3b397f91 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:34:07 +0200 Subject: [PATCH 06/20] FIX close-parent walk quadratic on large content (#8) --- src/Extensions/ShortcodeParserExtension.php | 126 ++++++++++++++------ tests/ShortcodeParserExtensionTest.php | 23 ++++ 2 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 91bcf94..e977413 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -111,49 +111,71 @@ private function closeParentElements(string $content, array $tags): string # 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. - $pattern = '%(?)' + # 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'; - if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { - return $content; - } - - # Tokenise: text between the matches, plus each match with its kind - $tokens = []; + . '|(?\[(?' . $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; - foreach ($matches as $match) { + $length = strlen($content); + while ($cursor < $length && preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $cursor)) { [$string, $offset] = $match[0]; if ($offset > $cursor) { - $tokens[] = ['text', substr($content, $cursor, $offset - $cursor), null]; + $kinds[] = 'text'; + $strings[] = substr($content, $cursor, $offset - $cursor); + $names[] = null; } if (isset($match['sc']) && $match['sc'][1] !== -1) { - $tokens[] = ['sc', $string, null]; + $kinds[] = 'sc'; + $names[] = null; } elseif (isset($match['tag']) && $match['tag'][1] !== -1) { - $tokens[] = ['tag', $string, strtolower($match['name'][0])]; + $kinds[] = 'tag'; + $names[] = strtolower($match['name'][0]); } else { - $tokens[] = ['text', $string, null]; + $kinds[] = 'text'; + $names[] = null; } - $cursor = $offset + strlen($string); + $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 ($cursor < strlen($content)) { - $tokens[] = ['text', substr($content, $cursor), null]; + 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; } - $out = ''; + # 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 = []; - $count = count($tokens); + $count = count($kinds); for ($i = 0; $i < $count; $i++) { - [$kind, $string, $name] = $tokens[$i]; + $kind = $kinds[$i]; + $string = $strings[$i]; + $name = $names[$i]; if ($kind === 'tag') { - $out .= $string; + $out[] = $string; $this->trackTag($stack, $string, $name); continue; } if ($kind !== 'sc' || !$stack) { # Text, comments, and a block shortcode with no wrapping element: nothing to close - $out .= $string; + $out[] = $string; continue; } @@ -167,31 +189,36 @@ private function closeParentElements(string $content, array $tags): string } #

    [sc] ->

    [sc] (a line break right before block output only adds a blank line) - $out = preg_replace('%\s*$%i', '', $out); + $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. - $trimmed = rtrim($out); - if (preg_match('%^<[a-z0-9]+\\s*>$%i', $openTag) && substr($trimmed, -strlen($openTag)) === $openTag) { - $out = substr($trimmed, 0, -strlen($openTag)); + # 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[] = ""; } } - $out .= $string; + $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 && $tokens[$next][0] === 'text' && trim($tokens[$next][1]) === '') { + while ($next < $count && $kinds[$next] === 'text' && trim($strings[$next]) === '') { $next++; } - if ($next < $count && $tokens[$next][0] === 'tag' && $tokens[$next][2] === $chain[0][0] - && strpos($tokens[$next][1], 'trackTag($stack, $tokens[$next][1], $tokens[$next][2]); + $this->trackTag($stack, $strings[$next], $names[$next]); array_shift($chain); $i = $next; continue; @@ -204,11 +231,42 @@ private function closeParentElements(string $content, array $tags): string # a styled span carries on after the shortcode - minus their id (and a link's name # anchor), which must stay unique. foreach (array_reverse($chain) as [$elName, $openTag]) { - $out .= $this->isInline($elName) ? $this->withoutUniqueAttributes($openTag, $elName) : "<{$elName}>"; + $out[] = $this->isInline($elName) ? $this->withoutUniqueAttributes($openTag, $elName) : "<{$elName}>"; } } - return $out; + 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; } /** diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 41ebcbf..cfa4ba0 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -247,6 +247,29 @@ public function testContainerParentsAwaitIssue9() $this->markTestIncomplete("Container parents: behaviour to be decided in issue #9, not asserted.\n" . implode("\n", $report)); } + /** + * 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. + */ + public function testCloseParentStaysLinearOnLargeContent() + { + $cases = [ + '60000 block shortcodes' => str_repeat('

    a [sc_block] b

    ', 60000), + '60000 after a line break' => str_repeat("

    a
    \n [sc_block]\n

    ", 60000), + '20000 unterminated comments' => str_repeat(' [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 tag that merely starts with the block tag's name is another shortcode 'other shortcode sharing the prefix' => [ '

    Some [sc_block_other] x

    ', From 74bf520a4dd2ce54d500d3832da399cc05578fe2 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 8 Oct 2026 13:35:23 +0200 Subject: [PATCH 08/20] DOCS changelog for the #8 follow-ups --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc0dee..0d2175f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ block content (``, `
  • `, `
    `, `
    `) and for `
    `, this means they are now
       closed and split too, where 5.1.0 left multi-line forms untouched. Whether they should be is open
       in #9.
    +  Also handled: comma-separated shortcode attributes (`[sc,id=1]`), custom elements with a hyphen
    +  in their name (``, treated as inline), and a reopened inline element no longer repeats its
    +  `id` (or a link's `name`). The pass is linear in the content size.
     
     ## 5.1.0 (2026-09-25)
     
    
    From b67dcaa8dba26ae17324f8d5937796ca147f9ae3 Mon Sep 17 00:00:00 2001
    From: micschk 
    Date: Sun, 11 Oct 2026 17:07:29 +0200
    Subject: [PATCH 09/20] FIX shortcode_close_parent leaves containers whole,
     closes only p/h1-h6/pre/address/dt/summary (#9)
    
    Option (b) from #9, chosen by mic for 5.1.1. A block shortcode closes inline ancestors and the
    nearest block element only if that element cannot hold block content. div, li, td, dd,
    blockquote, section and other containers are left whole on any number of lines, so list items and
    table cells around a block shortcode are no longer deleted or split. This also changes the 5.1.0
    result for a one-line 
    [sc]
    : the div is now kept (README + CHANGELOG). The #9 test rows now assert this behaviour (was an incomplete test), plus browser rows. CHANGELOG 5.1.1 dated 2026-10-11. --- CHANGELOG.md | 31 +++-- README.md | 7 ++ src/Extensions/ShortcodeParserExtension.php | 35 +++++- tests/ShortcodeParserExtensionTest.php | 118 ++++++++++++++++---- tests/browser/specs/close-parent.spec.ts | 7 ++ 5 files changed, 165 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2175f..19875c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## 5.1.1 (unreleased) +## 5.1.1 (2026-10-11) + +### Changed + +- **`shortcode_close_parent` only closes elements that cannot hold block content** (#9). A block + shortcode now closes `

    `, `

    `-`

    `, `
    `, `
    `, `
    ` and ``, 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 @@ -13,14 +27,13 @@ `[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 parent on a line of its own - (`

    \n[sc]\n

    `) was left alone; it is now closed as well. For containers that can already hold - block content (``, `
  • `, `
    `, `
    `) and for `
    `, this means they are now
    -  closed and split too, where 5.1.0 left multi-line forms untouched. Whether they should be is open
    -  in #9.
    -  Also handled: comma-separated shortcode attributes (`[sc,id=1]`), custom elements with a hyphen
    -  in their name (``, treated as inline), and a reopened inline element no longer repeats its
    -  `id` (or a link's `name`). The pass is linear in the content size.
    +  **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 pass that stays linear in the content size. ## 5.1.0 (2026-09-25) diff --git a/README.md b/README.md index 27752e4..191cb36 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,13 @@ If the shortcode sits inside inline elements (eg `

    Some [shortcode]`, `

    `-`
    `, `
    `, `
    `, `
    ` 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. +Up to 5.1.0 a container on the same line was closed too, so `
    [shortcode]
    ` lost its `
    `; since 5.1.1 +it is kept. + ## Shortcode placeholder images To display a nice image instead of the raw shortcode in the CMS editor, implement a getShortcodePlaceHolder method on your shortcodable object: diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index e977413..f7f69b3 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -85,13 +85,25 @@ public function onBeforeParse(&$content) '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. + */ + private const CLOSABLE_ELEMENTS = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'address', 'dt', 'summary', + ]; + /** * 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. An element without attributes that would be + * 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. * @@ -179,13 +191,26 @@ private function closeParentElements(string $content, array $tags): string continue; } - # The elements to close, innermost first: inline ancestors up to the first block one + # The elements to close, innermost first: 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). $chain = []; for ($s = count($stack) - 1; $s >= 0; $s--) { - $chain[] = $stack[$s]; - if (!$this->isInline($stack[$s][0])) { - break; + if ($this->isInline($stack[$s][0])) { + $chain[] = $stack[$s]; + continue; } + if (in_array($stack[$s][0], self::CLOSABLE_ELEMENTS)) { + $chain[] = $stack[$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) diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 323ce4f..4816989 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -255,30 +255,110 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() } /** - * Issue #9 (open, waiting on a decision): which elements a block shortcode may close when its - * parent is a container that can already hold block content (td, li, blockquote, section) or - * pre. 5.1.0 only looked within one line, so these multi-line forms were left alone; the - * tag walk now closes/splits the container. No behaviour is asserted here on purpose: the - * inputs are kept so the #9 decision has its cases ready. Each one is parsed so the rows at - * least run, and the test is reported as incomplete. + * Issue #9, option (b): a block shortcode closes only block elements that cannot hold block + * content (p, h1-h6, pre, address, dt, 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 testContainerParentsAwaitIssue9() + public function testContainerParentsAreLeftWhole() { + $b = '
    B
    '; $cases = [ - 'table cell, multi-line' => "\n\n
    \n[sc_block]\n
    ", - 'list item, multi-line' => "
      \n
    • \n[sc_block]\n
    • \n
    ", - 'blockquote, multi-line' => "
    \n[sc_block]\n
    ", - 'section, multi-line' => "
    \n[sc_block]\n
    ", - 'pre, multi-line' => "
    \nline\n[sc_block]\n
    ", - 'table cell, one line' => '
    [sc_block]
    ', - 'list item, one line' => '
    • One
    • [sc_block]
    ', - 'div, one line' => '
    [sc_block]
    ', + // 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
    ", + ], + // 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, multi-line' => [ + "
    \nline\n[sc_block]\n
    ", + "
    \nline\n
    $b", + ], + 'h4' => [ + '

    Title [sc_block]

    ', + "

    Title

    $b", + ], + 'address' => [ + '
    Street [sc_block] 1
    ', + "
    Street
    $b
    1
    ", + ], + 'definition term' => [ + '
    Term [sc_block]
    x
    ', + "
    Term
    $b
    x
    ", + ], + 'summary' => [ + '
    S [sc_block]body
    ', + "
    S {$b}body
    ", + ], ]; - $report = []; - foreach ($cases as $label => $input) { - $report[] = sprintf("%s\n input: %s\n output: %s", $label, json_encode($input), json_encode(ShortcodeParser::get()->parse($input))); + + $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->markTestIncomplete("Container parents: behaviour to be decided in issue #9, not asserted.\n" . implode("\n", $report)); + $this->assertSame([], $failures, count($failures) . ' of ' . count($cases) . " cases failed:\n" . implode("\n", $failures)); } /** diff --git a/tests/browser/specs/close-parent.spec.ts b/tests/browser/specs/close-parent.spec.ts index 578c868..ee5815e 100644 --- a/tests/browser/specs/close-parent.spec.ts +++ b/tests/browser/specs/close-parent.spec.ts @@ -17,6 +17,13 @@ const cases: [string, string, string][] = [ ['two in one paragraph', '

    A [scb_block] B [scb_block] C

    ', `

    A

    ${B}

    B

    ${B}

    C

    `], ['hr sibling before', '

    [scb_block]

    ', `
    ${B}`], ['top level, between paragraphs', '

    Intro

    [scb_block]

    Outro

    ', `

    Intro

    ${B}

    Outro

    `], + // Issue #9 (b): a container that can hold block content is left whole, on one line or several; + // only p, h1-h6, pre, address, dt and summary (plus inline ancestors) are closed + ['list item, multi-line (#9)', '
      \n
    • \n[scb_block]\n
    • \n
    ', `
      \n
    • \n${B}\n
    • \n
    `], + ['table cell, one line (#9)', '
    [scb_block]
    ', `
    ${B}
    `], + ['div, one line (#9)', '
    [scb_block]
    ', `
    ${B}
    `], + ['strong inside a list item (#9)', '
    • a [scb_block] b
    ', `
    • a ${B} b
    `], + ['pre is closed (#9)', '
    line\n[scb_block]\n
    ', `
    line\n
    ${B}`], ['inline shortcode stays inline', '

    Year

    It is [scb_year] now

    ', '

    Year

    It is 2026 now

    '], ]; From 7fe25ca58c6442e1458f1aa6f4d0f23512d82865 Mon Sep 17 00:00:00 2001 From: micschk Date: Sun, 11 Oct 2026 17:24:15 +0200 Subject: [PATCH 10/20] FIX close-parent tag match stops at a '>' inside a quoted attribute (#8) `
    x [sc] y` was cut at `a>`, so the reopened copy of the link pulled the following text into its title attribute. A tag now runs to the first '>' outside a quoted value (double or single quotes), with possessive quantifiers so an unterminated quote fails the tag instead of backtracking. Found by the verifier pass; 5.1.0 did not have this. --- src/Extensions/ShortcodeParserExtension.php | 6 +++++- tests/ShortcodeParserExtensionTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index f7f69b3..f12d5ab 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -127,8 +127,12 @@ private function closeParentElements(string $content, array $tags): string # the content (as it does for an HTML parser), instead of every '|$))' - . '|(?[a-z][a-z0-9-]*)(?=[\s/>])[^>]*>)' + . '|(?[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 diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 4816989..abd9a1c 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -236,6 +236,15 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() '

    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

    ', From e566607a5e908627d75e7fa80ac1a8609fc5dab1 Mon Sep 17 00:00:00 2001 From: micschk Date: Sun, 11 Oct 2026 17:25:51 +0200 Subject: [PATCH 11/20] FIX close-parent leaves address and dt whole: both take flow content (#9) WHATWG HTML Living Standard, content models: dt is "Flow content, but with no header, footer, sectioning content, or heading content descendants" (4.4.10); address is "Flow content, but with no heading content descendants, no sectioning content descendants, and no header, footer, or address element descendants" (4.3.10). Both can hold a
    , and closing a
    put the block output directly inside the
    . They are now containers like dd and li. p, h1-h6, pre (phrasing content) and summary (phrasing content, optionally with headings) are still closed. Decision by mic on the verifier pass. --- CHANGELOG.md | 8 ++++---- README.md | 6 +++--- src/Extensions/ShortcodeParserExtension.php | 5 ++++- tests/ShortcodeParserExtensionTest.php | 21 +++++++++++---------- tests/browser/specs/close-parent.spec.ts | 2 +- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19875c9..e0cd4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ ### Changed - **`shortcode_close_parent` only closes elements that cannot hold block content** (#9). A block - shortcode now closes `

    `, `

    `-`

    `, `
    `, `
    `, `
    ` and ``, 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 + 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 diff --git a/README.md b/README.md index 191cb36..06fc969 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ If the shortcode sits inside inline elements (eg `

    Some [shortcode]`, `

    `-`
    `, `
    `, `
    `, `
    ` 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 +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. Up to 5.1.0 a container on the same line was closed too, so `
    [shortcode]
    ` lost its `
    `; since 5.1.1 it is kept. diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index f12d5ab..5fe8261 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -90,9 +90,12 @@ public function onBeforeParse(&$content) * 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', 'address', 'dt', 'summary', + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'summary', ]; /** diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index abd9a1c..5ba66b5 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -265,7 +265,7 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() /** * Issue #9, option (b): a block shortcode closes only block elements that cannot hold block - * content (p, h1-h6, pre, address, dt, summary) plus inline ancestors. A container that can + * 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. @@ -312,6 +312,15 @@ public function testContainerParentsAreLeftWhole() '
    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]
    ', @@ -336,7 +345,7 @@ public function testContainerParentsAreLeftWhole() '
    • x [sc_block] y

    ', "
    • x

      $b

      y

    ", ], - // The closable elements besides p and h1-h6 (those are in the #8 table) + // 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", @@ -345,14 +354,6 @@ public function testContainerParentsAreLeftWhole() '

    Title [sc_block]

    ', "

    Title

    $b", ], - 'address' => [ - '
    Street [sc_block] 1
    ', - "
    Street
    $b
    1
    ", - ], - 'definition term' => [ - '
    Term [sc_block]
    x
    ', - "
    Term
    $b
    x
    ", - ], 'summary' => [ '
    S [sc_block]body
    ', "
    S {$b}body
    ", diff --git a/tests/browser/specs/close-parent.spec.ts b/tests/browser/specs/close-parent.spec.ts index ee5815e..faa1426 100644 --- a/tests/browser/specs/close-parent.spec.ts +++ b/tests/browser/specs/close-parent.spec.ts @@ -18,7 +18,7 @@ const cases: [string, string, string][] = [ ['hr sibling before', '

    [scb_block]

    ', `
    ${B}`], ['top level, between paragraphs', '

    Intro

    [scb_block]

    Outro

    ', `

    Intro

    ${B}

    Outro

    `], // Issue #9 (b): a container that can hold block content is left whole, on one line or several; - // only p, h1-h6, pre, address, dt and summary (plus inline ancestors) are closed + // only p, h1-h6, pre and summary (plus inline ancestors) are closed ['list item, multi-line (#9)', '
      \n
    • \n[scb_block]\n
    • \n
    ', `
      \n
    • \n${B}\n
    • \n
    `], ['table cell, one line (#9)', '
    [scb_block]
    ', `
    ${B}
    `], ['div, one line (#9)', '
    [scb_block]
    ', `
    ${B}
    `], From 7b442abbb8c18b9f8acf2dd191be787e1e2f748c Mon Sep 17 00:00:00 2001 From: micschk Date: Sun, 11 Oct 2026 17:26:55 +0200 Subject: [PATCH 12/20] FIX reopened inline element: drop only real id/name attributes, not id= text in other values (#8) The id/name strip matched `\s+id=` anywhere in the tag, so title=" id=1" lost its value and title="see id=" href="/x" was merged into one broken attribute. The tag is now walked with the same quote-aware alternation as the tag match: quoted values are consumed whole, and only a real id (or a link's name) attribute is dropped. Found by the verifier pass. --- src/Extensions/ShortcodeParserExtension.php | 15 +++++++++++++-- tests/ShortcodeParserExtensionTest.php | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 5fe8261..5ec3f62 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -308,8 +308,19 @@ private function lastNonBlankPart(array $out): ?int private function withoutUniqueAttributes(string $openTag, string $name): string { $attributes = $name === 'a' ? 'id|name' : 'id'; - # Whitespace before the name, so data-id or aria-name are left alone; quoted or bare values - return preg_replace('%\s+(?:' . $attributes . ')\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>"\']+)%i', '', $openTag); + # 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]; + }, + $openTag + ); } /** diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 5ba66b5..0981801 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -202,6 +202,15 @@ public function testCloseParentClosesTheShortcodesOwnWrappingElement() "

    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

    ', From 7d156352f22f74c60e3d87c8cb9559fe083f4e82 Mon Sep 17 00:00:00 2001 From: micschk Date: Sun, 11 Oct 2026 17:28:40 +0200 Subject: [PATCH 13/20] FIX close-parent cost on stray closers in deep nesting, cap inline depth at 10 (#8) Two shapes were not linear (verifier pass): - every stray closing tag scanned the whole open-element stack (20000 open divs and 20000 stray closers: 6 s). A per-name open count makes a stray closer O(1). - every shortcode closed and reopened every inline ancestor, so the output grew with depth x shortcodes (1600 nested spans and 1600 shortcodes: 43 KB in, 59 MB out). Past 10 inline ancestors (MAX_INLINE_DEPTH) the shortcode is now left in place. Perf rows and a depth test added; README and CHANGELOG note the limit. --- CHANGELOG.md | 6 +++- README.md | 1 + src/Extensions/ShortcodeParserExtension.php | 40 +++++++++++++++++---- tests/ShortcodeParserExtensionTest.php | 35 ++++++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cd4c1..befc4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,11 @@ 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 pass that stays linear in the content size. + anchor is not repeated, and a `>` inside a quoted attribute value. + **Limit:** a block shortcode inside more than 10 nested inline elements is left where it is, + untouched. Closing and reopening every level for every shortcode made the output grow with + nesting depth times the number of shortcodes; with the limit, the time and the output size grow + linearly with the content. ## 5.1.0 (2026-09-25) diff --git a/README.md b/README.md index 06fc969..cfc3634 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Only elements that cannot hold block content are closed: `

    `, `

    `-`

    `, ` 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 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.1.1 it is kept. diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 5ec3f62..5d74077 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -98,6 +98,15 @@ public function onBeforeParse(&$content) '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; + /** * Close the element(s) wrapping each block shortcode before it and reopen them after it, eg * `

    Some [sc] content

    ` becomes `

    Some

    [sc]

    content

    `. @@ -181,6 +190,10 @@ private function closeParentElements(string $content, array $tags): string $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]; @@ -189,7 +202,7 @@ private function closeParentElements(string $content, array $tags): string if ($kind === 'tag') { $out[] = $string; - $this->trackTag($stack, $string, $name); + $this->trackTag($stack, $openCounts, $string, $name); continue; } if ($kind !== 'sc' || !$stack) { @@ -202,9 +215,14 @@ private function closeParentElements(string $content, array $tags): string # 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: leave the shortcode in place (see there). $chain = []; for ($s = count($stack) - 1; $s >= 0; $s--) { if ($this->isInline($stack[$s][0])) { + if (count($chain) >= self::MAX_INLINE_DEPTH) { + $chain = []; + break; + } $chain[] = $stack[$s]; continue; } @@ -250,7 +268,7 @@ private function closeParentElements(string $content, array $tags): string if ($next < $count && $kinds[$next] === 'tag' && $names[$next] === $chain[0][0] && strpos($strings[$next], 'trackTag($stack, $strings[$next], $names[$next]); + $this->trackTag($stack, $openCounts, $strings[$next], $names[$next]); array_shift($chain); $i = $next; continue; @@ -336,16 +354,23 @@ private function isInline(string $name): bool * 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, string $tag, string $name): void + private function trackTag(array &$stack, array &$openCounts, string $tag, string $name): void { if (strpos($tag, '= 0; $s--) { - if ($stack[$s][0] === $name) { - array_splice($stack, $s); + # Closing tag: pop up to and including its element; a stray closing tag is ignored. The + # count check makes a stray one O(1); otherwise the scan only passes elements it pops, + # so each element is scanned past at most once after it was pushed. + if (empty($openCounts[$name])) { + return; + } + while ($stack) { + [$popped] = array_pop($stack); + $openCounts[$popped]--; + if ($popped === $name) { break; } } @@ -356,5 +381,6 @@ private function trackTag(array &$stack, string $tag, string $name): void return; } $stack[] = [$name, $tag]; + $openCounts[$name] = ($openCounts[$name] ?? 0) + 1; } } diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 0981801..06bcc73 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -394,6 +394,11 @@ public function testCloseParentStaysLinearOnLargeContent() '60000 after a line break' => str_repeat("

    a
    \n [sc_block]\n

    ", 60000), '20000 unterminated comments' => str_repeat('|$))' - . '|(?[a-z][a-z0-9-]*)(?=[\s/>])(?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+>)' + . '|(?[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 diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index 8bb2217..bb7ec63 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -431,6 +431,30 @@ public function testCloseParentStaysLinearOnLargeContent() str_repeat('
    ', $n) . str_repeat('', $n) . '

    [sc_block]

    ', str_repeat('
    ', $n) . str_repeat('', $n) . '[sc_block]', ], + // Tags that never close: each ' is closed as usual. When '<' was allowed inside a tag, every one of + // these scanned on to the end of the content (80000 ' [ + str_repeat(' [ + str_repeat("[sc_block]

    ', + str_repeat("
    [ + str_repeat('[sc_block]

    ', + str_repeat('
    ' anywhere after them: the slow shape itself, so the time limit is the guard + '60000 unclosed ` 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 1 KB, is left where it is, untouched. Closing + 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 time and the output size grow - linearly with the content. + 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) diff --git a/README.md b/README.md index 8432dc7..e9498dd 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ 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 1 KB, is left untouched (a guard against pathological markup). +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. diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 4013d80..3476600 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -111,9 +111,11 @@ public function onBeforeParse(&$content) * 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. + * 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 = 1024; + private const MAX_REOPEN_LENGTH = 8192; /** * Close the element(s) wrapping each block shortcode before it and reopen them after it, eg diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index bb7ec63..26fdd3c 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -523,19 +523,25 @@ public function testInlineDepthIsCapped() } /** - * Inline opening tags are reopened only up to MAX_REOPEN_LENGTH (1 KB) in total; with longer + * Inline opening tags are reopened only up to MAX_REOPEN_LENGTH (8 KB) in total; with longer * tags the shortcode is left in place. Without the cap every shortcode repeated the whole tag, * so a 20 KB title around 1000 shortcodes gave 31 KB in, 20 MB out. */ public function testReopenedTagLengthIsCapped() { - // Under the cap: the span is closed and reopened (its title included) around the shortcode - $content = '

    a [sc_block] b

    '; + // A long real-world link (an Outlook safelinks href is about 1.1 KB) is closed and reopened + $href = 'https://eur01.safelinks.protection.outlook.com/?url=' . str_repeat('a%2F', 270); + $content = '

    a [sc_block] b

    '; + ShortcodeParser::get()->extend('onBeforeParse', $content); + $this->assertStringContainsString('

    [sc_block]

    ', $content); + + // Just under the cap (the tag is 8115 bytes): closed and reopened, its title included + $content = '

    a [sc_block] b

    '; ShortcodeParser::get()->extend('onBeforeParse', $content); $this->assertStringContainsString('

    [sc_block]

    a [sc_block] b

    '; + // Just over the cap (8215 bytes): left as it was + $input = '

    a [sc_block] b

    '; $content = $input; ShortcodeParser::get()->extend('onBeforeParse', $content); $this->assertSame($input, $content);