From 4393053ad9e4e6d0493f0592ac49148838742f0d Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 17 Jul 2026 12:24:59 +0200 Subject: [PATCH 01/18] Add spaceless tag --- src/Tags/Spaceless.php | 48 ++++++++++++++++++++++++ tests/Tags/SpacelessTest.php | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/Tags/Spaceless.php create mode 100644 tests/Tags/SpacelessTest.php diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php new file mode 100644 index 00000000000..8e36168f2bb --- /dev/null +++ b/src/Tags/Spaceless.php @@ -0,0 +1,48 @@ +parse(); + + // Protect elements whose whitespace is significant so their contents are never touched. + $protected = []; + + $html = preg_replace_callback( + '/<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', + function ($matches) use (&$protected) { + $key = "\x02spaceless:".count($protected)."\x02"; + $protected[$key] = $matches[0]; + + return $key; + }, + $html + ); + + // A single HTML tag, aware of quoted attribute values so a literal '>' + // inside an attribute, isn't mistaken for the tag's closing bracket. + $tag = '<\/?[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; + + // Collapse whitespace runs into a single space, but only within text + // nodes — tags (and their attribute values) are left untouched. + $html = preg_replace_callback( + '/('.$tag.')|([^<]+)/u', + fn ($matches) => $matches[1] !== '' ? $matches[1] : preg_replace('/\s+/', ' ', $matches[2]), + $html + ); + + // Remove whitespace that sits directly beside a tag. + $html = preg_replace('/\s+('.$tag.')/u', '$1', $html); + $html = preg_replace('/('.$tag.')\s+/u', '$1', $html); + + $html = trim($html); + + // Restore the protected elements exactly as they were. + return strtr($html, $protected); + } +} diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php new file mode 100644 index 00000000000..2d98d9e623f --- /dev/null +++ b/tests/Tags/SpacelessTest.php @@ -0,0 +1,73 @@ + +
  • One
  • +
  • Two
  • + '; + + $this->assertEquals( + '', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_does_not_glue_words_separated_by_a_line_break() + { + $html = "

    \nHello\nWorld\n

    "; + + $this->assertEquals( + '

    Hello World

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_does_not_corrupt_an_attribute_value_containing_a_greater_than_sign() + { + $html = '
    Hi
    '; + + $this->assertEquals( + '
    Hi
    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_script_content_untouched() + { + $html = ""; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_pre_content_untouched() + { + $html = "
    function foo() {\n    return 1;\n}
    "; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } +} From 90efa8594467e05a664ccf6dfc64feae5d370cc7 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 17 Jul 2026 12:25:09 +0200 Subject: [PATCH 02/18] Register spaceless tag --- src/Providers/ExtensionServiceProvider.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index edace27a305..7cc4e8ff5ae 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -210,6 +210,7 @@ class ExtensionServiceProvider extends ServiceProvider Tags\Set::class, Tags\Section::class, Tags\Session::class, + Tags\Spaceless::class, Tags\Structure::class, Tags\Svg::class, Tags\Taxonomy\Taxonomy::class, From 8b83a69aeb2bb0d75aede526e863d925449db409 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 17 Jul 2026 12:25:14 +0200 Subject: [PATCH 03/18] Add more spaceless tag tests --- tests/Tags/SpacelessTest.php | 132 +++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 5 deletions(-) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index 2d98d9e623f..dee18218247 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -8,11 +8,6 @@ class SpacelessTest extends TestCase { - private function tag($tag, $data = []) - { - return (string) Parse::template($tag, $data, trusted: true); - } - #[Test] public function it_strips_whitespace_between_tags() { @@ -70,4 +65,131 @@ public function it_leaves_pre_content_untouched() $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } + + #[Test] + public function it_leaves_style_content_untouched() + { + $html = ""; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_textarea_content_untouched() + { + $html = ""; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_protects_elements_regardless_of_tag_name_case() + { + $html = ""; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_still_strips_whitespace_around_a_protected_element() + { + $html = "
    \n \n
    "; + + $this->assertEquals( + "
    ", + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_restores_multiple_protected_elements_independently() + { + $html = "\n
       x   y   
    "; + + $this->assertEquals( + "
       x   y   
    ", + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_handles_an_attribute_value_containing_a_single_quote() + { + $html = "
    Hi
    "; + + $this->assertEquals( + "
    Hi
    ", + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_strips_whitespace_around_self_closing_tags() + { + $html = '

    '; + + $this->assertEquals( + '

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_collapses_tabs_and_multiple_spaces_within_text() + { + $html = "

    Hello\t\t World

    "; + + $this->assertEquals( + '

    Hello World

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_strips_whitespace_after_an_opening_tag_and_before_a_closing_tag() + { + $html = '
    Hello
    '; + + $this->assertEquals( + '
    Hello
    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_whitespace_between_attributes_untouched() + { + $html = "Hi"; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_returns_an_empty_string_when_content_is_only_whitespace() + { + $html = " \n \t "; + + $this->assertEquals( + '', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + private function tag($tag): string + { + $data = []; + return (string) Parse::template($tag, $data, trusted: true); + } } From 40d1becbe44ebcfe418b8fb485c7099f0de3aa49 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 17 Jul 2026 12:38:33 +0200 Subject: [PATCH 04/18] =?UTF-8?q?=F0=9F=8D=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Tags/SpacelessTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index dee18218247..f2a9d725d27 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -190,6 +190,7 @@ public function it_returns_an_empty_string_when_content_is_only_whitespace() private function tag($tag): string { $data = []; + return (string) Parse::template($tag, $data, trusted: true); } } From 893f660c17a9e39ee9906e177f2d8700697f5ab1 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Fri, 17 Jul 2026 17:04:44 +0200 Subject: [PATCH 05/18] Scope spaceless tag's whitespace stripping to tag boundaries Previously stripped whitespace beside any tag, which glued inline tags to surrounding prose (e.g. text before/after a link). Now only strips between two tags, or at the inner edge of a tag's own content. --- src/Tags/Spaceless.php | 12 +++++++++--- tests/Tags/SpacelessTest.php | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 8e36168f2bb..64a96a9c65d 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -36,9 +36,15 @@ function ($matches) use (&$protected) { $html ); - // Remove whitespace that sits directly beside a tag. - $html = preg_replace('/\s+('.$tag.')/u', '$1', $html); - $html = preg_replace('/('.$tag.')\s+/u', '$1', $html); + // Remove whitespace that sits directly between two tags. + $html = preg_replace('/('.$tag.')\s+(?='.$tag.')/u', '$1', $html); + + // Also, trim right after an opening tag and right before a closing + // tag, but not whitespace touching external text, so prose stays intact. + $opening = '<(?!\/)[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*(?'; + $closing = '<\/[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; + $html = preg_replace('/('.$opening.')\s+/u', '$1', $html); + $html = preg_replace('/\s+('.$closing.')/u', '$1', $html); $html = trim($html); diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index f2a9d725d27..d0e368f39c5 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -176,6 +176,17 @@ public function it_leaves_whitespace_between_attributes_untouched() ); } + #[Test] + public function it_does_not_glue_prose_around_an_inline_tag() + { + $html = '

    Check out this link for more info.

    '; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From 57ae5e92ab790be0064c1a781045e94d67d6c8ce Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 09:47:48 +0200 Subject: [PATCH 06/18] Preserve real spacing next to a tag glued to prose A tag with no gap from the word before it (e.g. is) could still have meaningful whitespace on its inner edge; only trim that edge when the tag itself sits at an actual boundary. --- src/Tags/Spaceless.php | 8 ++++---- tests/Tags/SpacelessTest.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 64a96a9c65d..031fc4eb022 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -39,12 +39,12 @@ function ($matches) use (&$protected) { // Remove whitespace that sits directly between two tags. $html = preg_replace('/('.$tag.')\s+(?='.$tag.')/u', '$1', $html); - // Also, trim right after an opening tag and right before a closing - // tag, but not whitespace touching external text, so prose stays intact. + // Trim after an opening tag / before a closing tag, but only when + // a boundary (whitespace, tag, or string edge) exists on the other side. $opening = '<(?!\/)[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*(?'; $closing = '<\/[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; - $html = preg_replace('/('.$opening.')\s+/u', '$1', $html); - $html = preg_replace('/\s+('.$closing.')/u', '$1', $html); + $html = preg_replace('/(?:^|(?<=[\s>]))('.$opening.')\s+/u', '$1', $html); + $html = preg_replace('/\s+('.$closing.')(?=$|[\s<])/u', '$1', $html); $html = trim($html); diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index d0e368f39c5..d1104973706 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -187,6 +187,17 @@ public function it_does_not_glue_prose_around_an_inline_tag() ); } + #[Test] + public function it_does_not_glue_prose_around_whitespace_just_inside_an_inline_tag() + { + $html = 'This is important info.'; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From 7f672665f153bccd80967c4678d815afb56143ed Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 09:48:06 +0200 Subject: [PATCH 07/18] Keep plain spaces between tags, only glue on line breaks A plain space between two tags is usually intentional; a line break there is almost always formatter indentation, so only that gets stripped now instead of every gap between tags unconditionally. --- src/Tags/Spaceless.php | 20 ++++++++++++++------ tests/Tags/SpacelessTest.php | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 031fc4eb022..589e43e6115 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -28,17 +28,25 @@ function ($matches) use (&$protected) { // inside an attribute, isn't mistaken for the tag's closing bracket. $tag = '<\/?[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; - // Collapse whitespace runs into a single space, but only within text - // nodes — tags (and their attribute values) are left untouched. + // Collapse whitespace in text nodes to a single space; tags untouched. + // Whitespace-only nodes (i.e. directly between two tags) collapse to + // a space too, unless they contain a newline, then they're removed. $html = preg_replace_callback( '/('.$tag.')|([^<]+)/u', - fn ($matches) => $matches[1] !== '' ? $matches[1] : preg_replace('/\s+/', ' ', $matches[2]), + function ($matches) { + if ($matches[1] !== '') { + return $matches[1]; + } + + if (trim($matches[2]) === '') { + return str_contains($matches[2], "\n") ? '' : ' '; + } + + return preg_replace('/\s+/', ' ', $matches[2]); + }, $html ); - // Remove whitespace that sits directly between two tags. - $html = preg_replace('/('.$tag.')\s+(?='.$tag.')/u', '$1', $html); - // Trim after an opening tag / before a closing tag, but only when // a boundary (whitespace, tag, or string edge) exists on the other side. $opening = '<(?!\/)[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*(?'; diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index d1104973706..56af3ce775d 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -133,12 +133,12 @@ public function it_handles_an_attribute_value_containing_a_single_quote() } #[Test] - public function it_strips_whitespace_around_self_closing_tags() + public function it_keeps_a_space_between_self_closing_tags_but_trims_a_containers_own_padding_spaces() { $html = '

    '; $this->assertEquals( - '

    ', + '

    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } @@ -198,6 +198,35 @@ public function it_does_not_glue_prose_around_whitespace_just_inside_an_inline_t ); } + #[Test] + public function it_keeps_a_plain_space_between_adjacent_inline_tags() + { + $html = 'a b'; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_glues_adjacent_tags_separated_by_a_line_break_and_removes_spaces() + { + $html = "a\nb"; + + $this->assertEquals( + 'ab', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + + $html = "a \n \r b"; + + $this->assertEquals( + 'ab', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From efc4389229fbbe85a7579e775d216ae55f41b379 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 09:49:03 +0200 Subject: [PATCH 08/18] Rewrite whitespace handling as a token pass, fix two glue bugs The regex-chain approach couldn't see past a single neighboring tag, so two bugs slipped through: whitespace inside a tag directly chained onto another tag was stripped even when both were glued to real prose, and a line break glued to prose right inside a tag survived as a literal space instead of being treated as formatter noise. Tokenizing into tags/whitespace/prose and walking through chained tags to find the nearest real content fixes both. --- src/Tags/Spaceless.php | 108 +++++++++++++++++++++++++++-------- tests/Tags/SpacelessTest.php | 22 +++++++ 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 589e43e6115..41fc0e37c86 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -24,39 +24,97 @@ function ($matches) use (&$protected) { $html ); + return strtr($this->collapse($html), $protected); + } + + /** + * Tokenize into tags, whitespace runs, and prose runs, then decide + * per whitespace token whether to remove it or collapse it to a + * single space. + */ + private function collapse(string $html): string + { // A single HTML tag, aware of quoted attribute values so a literal '>' // inside an attribute, isn't mistaken for the tag's closing bracket. $tag = '<\/?[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; - // Collapse whitespace in text nodes to a single space; tags untouched. - // Whitespace-only nodes (i.e. directly between two tags) collapse to - // a space too, unless they contain a newline, then they're removed. - $html = preg_replace_callback( - '/('.$tag.')|([^<]+)/u', - function ($matches) { - if ($matches[1] !== '') { - return $matches[1]; - } + preg_match_all('/'.$tag.'/u', $html, $tagMatches, PREG_OFFSET_CAPTURE); - if (trim($matches[2]) === '') { - return str_contains($matches[2], "\n") ? '' : ' '; - } + $tokens = []; + $cursor = 0; - return preg_replace('/\s+/', ' ', $matches[2]); - }, - $html - ); + $pushText = function (string $text) use (&$tokens) { + if ($text === '') { + return; + } + + preg_match_all('/(\s+)|(\S+)/u', $text, $pieces, PREG_SET_ORDER); + + foreach ($pieces as $piece) { + $tokens[] = $piece[1] !== '' + ? ['type' => 'ws', 'value' => $piece[1]] + : ['type' => 'prose', 'value' => $piece[2]]; + } + }; + + foreach ($tagMatches[0] as [$tagString, $offset]) { + $pushText(substr($html, $cursor, $offset - $cursor)); + + $type = str_starts_with($tagString, '') ? 'void' : 'opening'); + + $tokens[] = ['type' => $type, 'value' => $tagString]; + $cursor = $offset + strlen($tagString); + } + $pushText(substr($html, $cursor)); + + $isTag = fn ($type) => in_array($type, ['opening', 'closing', 'void'], true); + + // Whether the tag at $tagIndex is, ignoring any further tags in + // between, glued (no whitespace) to real content on the given side. + $externallyGlued = function (int $tagIndex, int $direction) use ($tokens, $isTag) { + $i = $tagIndex + $direction; + + while (isset($tokens[$i]) && $isTag($tokens[$i]['type'])) { + $i += $direction; + } + + return isset($tokens[$i]) && $tokens[$i]['type'] === 'prose'; + }; + + $html = ''; + + foreach ($tokens as $i => $token) { + if ($token['type'] !== 'ws') { + $html .= $token['value']; + + continue; + } + + $hasNewline = str_contains($token['value'], "\n"); + $left = $tokens[$i - 1] ?? null; + $right = $tokens[$i + 1] ?? null; - // Trim after an opening tag / before a closing tag, but only when - // a boundary (whitespace, tag, or string edge) exists on the other side. - $opening = '<(?!\/)[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*(?'; - $closing = '<\/[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; - $html = preg_replace('/(?:^|(?<=[\s>]))('.$opening.')\s+/u', '$1', $html); - $html = preg_replace('/\s+('.$closing.')(?=$|[\s<])/u', '$1', $html); + if ($left && $left['type'] === 'opening') { + // Whitespace right inside an opening tag: a container's own + // padding, only kept if the tag itself is glued to content + // outside it (e.g. `is important`). + $keep = ! $hasNewline && $externallyGlued($i - 1, -1); + } elseif ($right && $right['type'] === 'closing') { + $keep = ! $hasNewline && $externallyGlued($i + 1, 1); + } elseif ($left && $right && $left['type'] === 'prose' && $right['type'] === 'prose') { + // Mid-sentence whitespace always separates words, even across a line break. + $keep = true; + } else { + // A peer gap (e.g. between two tags, or beside a void tag): + // keep a real space, but a line break is formatter noise. + $keep = ! $hasNewline; + } - $html = trim($html); + $html .= $keep ? ' ' : ''; + } - // Restore the protected elements exactly as they were. - return strtr($html, $protected); + return trim($html); } } diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index 56af3ce775d..fc318611959 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -227,6 +227,28 @@ public function it_glues_adjacent_tags_separated_by_a_line_break_and_removes_spa ); } + #[Test] + public function it_keeps_a_space_inside_a_tag_chained_directly_onto_another_tag() + { + $html = 'a b c d'; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_glues_a_line_break_inside_a_tag_glued_to_prose() + { + $html = "
    a\n b\nc
    "; + + $this->assertEquals( + '
    abc
    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From 3b409d14ab10bb53327129def146d4823c99de20 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 09:58:39 +0200 Subject: [PATCH 09/18] Recognize void elements without a trailing slash Void tags like
    and
    are commonly written without the self-closing slash, but were only classified void when they had one. Without it they acted like a container, stripping whitespace on one side but not the other. --- src/Tags/Spaceless.php | 11 ++++++++--- tests/Tags/SpacelessTest.php | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 41fc0e37c86..da51df76654 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -6,6 +6,8 @@ class Spaceless extends Tags { private const PROTECTED_ELEMENTS = 'script|style|pre|textarea'; + private const VOID_ELEMENTS = 'area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr'; + public function index(): string { $html = (string) $this->parse(); @@ -60,9 +62,12 @@ private function collapse(string $html): string foreach ($tagMatches[0] as [$tagString, $offset]) { $pushText(substr($html, $cursor, $offset - $cursor)); - $type = str_starts_with($tagString, '') ? 'void' : 'opening'); + $type = match (true) { + str_starts_with($tagString, ' 'closing', + str_ends_with($tagString, '/>') => 'void', + (bool) preg_match('/^<('.self::VOID_ELEMENTS.')\b/i', $tagString) => 'void', + default => 'opening', + }; $tokens[] = ['type' => $type, 'value' => $tagString]; $cursor = $offset + strlen($tagString); diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index fc318611959..e7ff0822a91 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -249,6 +249,17 @@ public function it_glues_a_line_break_inside_a_tag_glued_to_prose() ); } + #[Test] + public function it_treats_a_void_element_without_a_trailing_slash_as_a_peer_not_a_container() + { + $html = '
    text

    more
    '; + + $this->assertEquals( + '
    text

    more
    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From c2de518e203072e722f220e28927616d48b9b2a4 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 10:03:51 +0200 Subject: [PATCH 10/18] Protect HTML comments from whitespace collapsing --- src/Tags/Spaceless.php | 4 ++-- tests/Tags/SpacelessTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index da51df76654..fd3819b1bd7 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -12,11 +12,11 @@ public function index(): string { $html = (string) $this->parse(); - // Protect elements whose whitespace is significant so their contents are never touched. + // Protect whitespace-sensitive elements and comments from any changes. $protected = []; $html = preg_replace_callback( - '/<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', + '/|<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', function ($matches) use (&$protected) { $key = "\x02spaceless:".count($protected)."\x02"; $protected[$key] = $matches[0]; diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index e7ff0822a91..67a341a6664 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -88,6 +88,17 @@ public function it_leaves_textarea_content_untouched() ); } + #[Test] + public function it_leaves_comment_content_untouched() + { + $html = "
    \n \n

    Hi

    \n
    "; + + $this->assertEquals( + '

    Hi

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_protects_elements_regardless_of_tag_name_case() { From 9038033a5294d4fad6598c18ffac9908476255f9 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 10:13:37 +0200 Subject: [PATCH 11/18] Extract void-tag check and a shared protect() helper --- src/Tags/Spaceless.php | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index fd3819b1bd7..5226dd11c8d 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -12,23 +12,31 @@ public function index(): string { $html = (string) $this->parse(); - // Protect whitespace-sensitive elements and comments from any changes. $protected = []; - $html = preg_replace_callback( - '/|<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', - function ($matches) use (&$protected) { - $key = "\x02spaceless:".count($protected)."\x02"; - $protected[$key] = $matches[0]; + // Comments aren't rendered, so their whitespace is never touched either. + $html = $this->protect($html, '//s', $protected); - return $key; - }, - $html + // Protect elements whose whitespace is significant. + $html = $this->protect( + $html, + '/<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', + $protected ); return strtr($this->collapse($html), $protected); } + private function protect(string $html, string $pattern, array &$protected): string + { + return preg_replace_callback($pattern, function ($matches) use (&$protected) { + $key = "\x02spaceless:".count($protected)."\x02"; + $protected[$key] = $matches[0]; + + return $key; + }, $html); + } + /** * Tokenize into tags, whitespace runs, and prose runs, then decide * per whitespace token whether to remove it or collapse it to a @@ -64,8 +72,7 @@ private function collapse(string $html): string $type = match (true) { str_starts_with($tagString, ' 'closing', - str_ends_with($tagString, '/>') => 'void', - (bool) preg_match('/^<('.self::VOID_ELEMENTS.')\b/i', $tagString) => 'void', + $this->hasNoClosingTag($tagString) => 'void', default => 'opening', }; @@ -122,4 +129,12 @@ private function collapse(string $html): string return trim($html); } + + // Self-closed syntax or a known void element name — either way, it has + // no separate closing tag and no interior content of its own to trim. + private function hasNoClosingTag(string $tagString): bool + { + return str_ends_with($tagString, '/>') + || preg_match('/^<('.self::VOID_ELEMENTS.')\b/i', $tagString) === 1; + } } From b1c6630dfece0a9ad69ec008874756247883141a Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 10:50:36 +0200 Subject: [PATCH 12/18] Fix nested comment/protected-element and hyphenated-tag bugs --- src/Tags/Spaceless.php | 35 +++++++++++++++++++---------------- tests/Tags/SpacelessTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 5226dd11c8d..cb73354fbb0 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -8,35 +8,28 @@ class Spaceless extends Tags private const VOID_ELEMENTS = 'area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr'; + // Must be this explicit lookahead, not \b — \b doesn't handle hyphens + // correctly and would wrongly treat as void. + private const VOID_ELEMENTS_PATTERN = '/^<('.self::VOID_ELEMENTS.')(?=[\s\/>])/i'; + public function index(): string { $html = (string) $this->parse(); $protected = []; - // Comments aren't rendered, so their whitespace is never touched either. - $html = $this->protect($html, '//s', $protected); - - // Protect elements whose whitespace is significant. + // Replace comments and whitespace-significant elements with placeholder + // tokens so collapse() never touches them; strtr() restores them after. $html = $this->protect( $html, - '/<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', + '/|<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', $protected ); + // Collapse whitespace, then swap the placeholder tokens back for the originals. return strtr($this->collapse($html), $protected); } - private function protect(string $html, string $pattern, array &$protected): string - { - return preg_replace_callback($pattern, function ($matches) use (&$protected) { - $key = "\x02spaceless:".count($protected)."\x02"; - $protected[$key] = $matches[0]; - - return $key; - }, $html); - } - /** * Tokenize into tags, whitespace runs, and prose runs, then decide * per whitespace token whether to remove it or collapse it to a @@ -130,11 +123,21 @@ private function collapse(string $html): string return trim($html); } + private function protect(string $html, string $pattern, array &$protected): string + { + return preg_replace_callback($pattern, function ($matches) use (&$protected) { + $key = "\x02spaceless:".count($protected)."\x02"; + $protected[$key] = $matches[0]; + + return $key; + }, $html); + } + // Self-closed syntax or a known void element name — either way, it has // no separate closing tag and no interior content of its own to trim. private function hasNoClosingTag(string $tagString): bool { return str_ends_with($tagString, '/>') - || preg_match('/^<('.self::VOID_ELEMENTS.')\b/i', $tagString) === 1; + || preg_match(self::VOID_ELEMENTS_PATTERN, $tagString) === 1; } } diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index 67a341a6664..b5d912c45fa 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -99,6 +99,28 @@ public function it_leaves_comment_content_untouched() ); } + #[Test] + public function it_leaves_a_comment_nested_inside_a_protected_element_untouched() + { + $html = ''; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_a_protected_element_example_nested_inside_a_comment_untouched() + { + $html = ''; + + $this->assertEquals( + $html, + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_protects_elements_regardless_of_tag_name_case() { @@ -271,6 +293,17 @@ public function it_treats_a_void_element_without_a_trailing_slash_as_a_peer_not_ ); } + #[Test] + public function it_does_not_treat_a_hyphenated_custom_element_as_void() + { + $html = ' Hello '; + + $this->assertEquals( + 'Hello', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_returns_an_empty_string_when_content_is_only_whitespace() { From 35d33ae8bf73098719ff41277a670e22a46d4c47 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 11:15:35 +0200 Subject: [PATCH 13/18] Inline variable --- tests/Tags/SpacelessTest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index b5d912c45fa..e7ccb09ce77 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -317,8 +317,6 @@ public function it_returns_an_empty_string_when_content_is_only_whitespace() private function tag($tag): string { - $data = []; - - return (string) Parse::template($tag, $data, trusted: true); + return (string) Parse::template(str: $tag, trusted: true); } } From 857b2a66575f10fc70c276ce5c161907641998dd Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 15:59:01 +0200 Subject: [PATCH 14/18] Split collapse() into smaller methods --- src/Tags/Spaceless.php | 169 +++++++++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 67 deletions(-) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index cb73354fbb0..31474caf488 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -8,26 +8,38 @@ class Spaceless extends Tags private const VOID_ELEMENTS = 'area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr'; - // Must be this explicit lookahead, not \b — \b doesn't handle hyphens - // correctly and would wrongly treat as void. - private const VOID_ELEMENTS_PATTERN = '/^<('.self::VOID_ELEMENTS.')(?=[\s\/>])/i'; - public function index(): string { $html = (string) $this->parse(); $protected = []; - // Replace comments and whitespace-significant elements with placeholder - // tokens so collapse() never touches them; strtr() restores them after. - $html = $this->protect( + $html = $this->protectSignificantWhitespace($html, $protected); + + return strtr($this->collapse($html), $protected); + } + + /** + * Replace comments and whitespace-significant elements with + * placeholder tokens so whitespace collapsing never touches them. + */ + private function protectSignificantWhitespace(string $html, array &$protected): string + { + return $this->protect( $html, '/|<('.self::PROTECTED_ELEMENTS.')\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>.*?<\/\1>/is', $protected ); + } - // Collapse whitespace, then swap the placeholder tokens back for the originals. - return strtr($this->collapse($html), $protected); + private function protect(string $html, string $pattern, array &$protected): string + { + return preg_replace_callback($pattern, function ($matches) use (&$protected) { + $key = "\x02spaceless:".count($protected)."\x02"; + $protected[$key] = $matches[0]; + + return $key; + }, $html); } /** @@ -36,6 +48,13 @@ public function index(): string * single space. */ private function collapse(string $html): string + { + $tokens = $this->tokenize($html); + + return $this->reduceWhitespace($tokens); + } + + private function tokenize(string $html): array { // A single HTML tag, aware of quoted attribute values so a literal '>' // inside an attribute, isn't mistaken for the tag's closing bracket. @@ -46,22 +65,8 @@ private function collapse(string $html): string $tokens = []; $cursor = 0; - $pushText = function (string $text) use (&$tokens) { - if ($text === '') { - return; - } - - preg_match_all('/(\s+)|(\S+)/u', $text, $pieces, PREG_SET_ORDER); - - foreach ($pieces as $piece) { - $tokens[] = $piece[1] !== '' - ? ['type' => 'ws', 'value' => $piece[1]] - : ['type' => 'prose', 'value' => $piece[2]]; - } - }; - foreach ($tagMatches[0] as [$tagString, $offset]) { - $pushText(substr($html, $cursor, $offset - $cursor)); + $this->pushTextTokens(substr($html, $cursor, $offset - $cursor), $tokens); $type = match (true) { str_starts_with($tagString, ' 'closing', @@ -72,22 +77,43 @@ private function collapse(string $html): string $tokens[] = ['type' => $type, 'value' => $tagString]; $cursor = $offset + strlen($tagString); } - $pushText(substr($html, $cursor)); - $isTag = fn ($type) => in_array($type, ['opening', 'closing', 'void'], true); + $this->pushTextTokens(substr($html, $cursor), $tokens); - // Whether the tag at $tagIndex is, ignoring any further tags in - // between, glued (no whitespace) to real content on the given side. - $externallyGlued = function (int $tagIndex, int $direction) use ($tokens, $isTag) { - $i = $tagIndex + $direction; + return $tokens; + } - while (isset($tokens[$i]) && $isTag($tokens[$i]['type'])) { - $i += $direction; - } + private function pushTextTokens(string $text, array &$tokens): void + { + if ($text === '') { + return; + } + + preg_match_all('/(\s+)|(\S+)/u', $text, $pieces, PREG_SET_ORDER); + + foreach ($pieces as $piece) { + $tokens[] = $piece[1] !== '' + ? ['type' => 'ws', 'value' => $piece[1]] + : ['type' => 'prose', 'value' => $piece[2]]; + } + } - return isset($tokens[$i]) && $tokens[$i]['type'] === 'prose'; - }; + /** + * Self-closed syntax or a known void element name — either way, it has + * no separate closing tag and no interior content of its own to trim. + */ + private function hasNoClosingTag(string $tagString): bool + { + // Must be this explicit lookahead, not \b — \b doesn't handle hyphens + // correctly and would wrongly treat as void. + $pattern = '/^<('.self::VOID_ELEMENTS.')(?=[\s\/>])/i'; + + return str_ends_with($tagString, '/>') + || preg_match($pattern, $tagString) === 1; + } + private function reduceWhitespace(array $tokens): string + { $html = ''; foreach ($tokens as $i => $token) { @@ -97,47 +123,56 @@ private function collapse(string $html): string continue; } - $hasNewline = str_contains($token['value'], "\n"); - $left = $tokens[$i - 1] ?? null; - $right = $tokens[$i + 1] ?? null; - - if ($left && $left['type'] === 'opening') { - // Whitespace right inside an opening tag: a container's own - // padding, only kept if the tag itself is glued to content - // outside it (e.g. `is important`). - $keep = ! $hasNewline && $externallyGlued($i - 1, -1); - } elseif ($right && $right['type'] === 'closing') { - $keep = ! $hasNewline && $externallyGlued($i + 1, 1); - } elseif ($left && $right && $left['type'] === 'prose' && $right['type'] === 'prose') { - // Mid-sentence whitespace always separates words, even across a line break. - $keep = true; - } else { - // A peer gap (e.g. between two tags, or beside a void tag): - // keep a real space, but a line break is formatter noise. - $keep = ! $hasNewline; - } - - $html .= $keep ? ' ' : ''; + $html .= $this->shouldKeepWhitespace($tokens, $i) ? ' ' : ''; } return trim($html); } - private function protect(string $html, string $pattern, array &$protected): string + private function shouldKeepWhitespace(array $tokens, int $i): bool { - return preg_replace_callback($pattern, function ($matches) use (&$protected) { - $key = "\x02spaceless:".count($protected)."\x02"; - $protected[$key] = $matches[0]; + $hasNewline = str_contains($tokens[$i]['value'], "\n"); + $left = $tokens[$i - 1] ?? null; + $right = $tokens[$i + 1] ?? null; + + if ($left && $left['type'] === 'opening') { + // Whitespace right inside an opening tag: a container's own + // padding, only kept if the tag itself is glued to content + // outside it (e.g. `is important`). + return ! $hasNewline && $this->externallyGlued($tokens, $i - 1, -1); + } - return $key; - }, $html); + if ($right && $right['type'] === 'closing') { + return ! $hasNewline && $this->externallyGlued($tokens, $i + 1, 1); + } + + if ($left && $right && $left['type'] === 'prose' && $right['type'] === 'prose') { + // Mid-sentence whitespace always separates words, even across a line break. + return true; + } + + // A peer gap (e.g. between two tags, or beside a void tag): + // keep a real space, but a line break is formatter noise. + return ! $hasNewline; } - // Self-closed syntax or a known void element name — either way, it has - // no separate closing tag and no interior content of its own to trim. - private function hasNoClosingTag(string $tagString): bool + /** + * Whether the tag at the given index is, ignoring any further tags + * in between, glued (no whitespace) to real content in the given direction. + */ + private function externallyGlued(array $tokens, int $tagIndex, int $direction): bool { - return str_ends_with($tagString, '/>') - || preg_match(self::VOID_ELEMENTS_PATTERN, $tagString) === 1; + $i = $tagIndex + $direction; + + while (isset($tokens[$i]) && $this->isTagToken($tokens[$i]['type'])) { + $i += $direction; + } + + return isset($tokens[$i]) && $tokens[$i]['type'] === 'prose'; + } + + private function isTagToken(string $type): bool + { + return in_array($type, ['opening', 'closing', 'void'], true); } } From 792fe9fc4deaa2acd62007f35594fcf75cce73fd Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 16:46:01 +0200 Subject: [PATCH 15/18] Fix whitespace-only tag content losing its glue check --- src/Tags/Spaceless.php | 9 +++++++++ tests/Tags/SpacelessTest.php | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php index 31474caf488..656d7fd0ca3 100644 --- a/src/Tags/Spaceless.php +++ b/src/Tags/Spaceless.php @@ -135,6 +135,15 @@ private function shouldKeepWhitespace(array $tokens, int $i): bool $left = $tokens[$i - 1] ?? null; $right = $tokens[$i + 1] ?? null; + if ($left && $right && $left['type'] === 'opening' && $right['type'] === 'closing') { + // Whitespace that is a tag's entire content: padding on both + // sides at once, only kept if the tag is glued to content on + // both outside edges (e.g. `is important`). + return ! $hasNewline + && $this->externallyGlued($tokens, $i - 1, -1) + && $this->externallyGlued($tokens, $i + 1, 1); + } + if ($left && $left['type'] === 'opening') { // Whitespace right inside an opening tag: a container's own // padding, only kept if the tag itself is glued to content diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index e7ccb09ce77..22dc63d3769 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -198,6 +198,28 @@ public function it_strips_whitespace_after_an_opening_tag_and_before_a_closing_t ); } + #[Test] + public function it_strips_whitespace_only_content_even_when_only_one_side_is_glued() + { + $html = 'x '; + + $this->assertEquals( + 'x', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_keeps_whitespace_only_content_when_glued_on_both_sides() + { + $html = 'x y'; + + $this->assertEquals( + 'x y', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + #[Test] public function it_leaves_whitespace_between_attributes_untouched() { From 1ee084bad242cf21f07d3a0ccefcad4e8b823cc9 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 16:46:10 +0200 Subject: [PATCH 16/18] Reorder spaceless tests core-first, niche last --- tests/Tags/SpacelessTest.php | 146 +++++++++++++++++------------------ 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index 22dc63d3769..ad671c63355 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -34,42 +34,42 @@ public function it_does_not_glue_words_separated_by_a_line_break() } #[Test] - public function it_does_not_corrupt_an_attribute_value_containing_a_greater_than_sign() + public function it_collapses_tabs_and_multiple_spaces_within_text() { - $html = '
    Hi
    '; + $html = "

    Hello\t\t World

    "; $this->assertEquals( - '
    Hi
    ', + '

    Hello World

    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_leaves_script_content_untouched() + public function it_strips_whitespace_after_an_opening_tag_and_before_a_closing_tag() { - $html = ""; + $html = '
    Hello
    '; $this->assertEquals( - $html, + '
    Hello
    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_leaves_pre_content_untouched() + public function it_returns_an_empty_string_when_content_is_only_whitespace() { - $html = "
    function foo() {\n    return 1;\n}
    "; + $html = " \n \t "; $this->assertEquals( - $html, + '', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_leaves_style_content_untouched() + public function it_does_not_glue_prose_around_an_inline_tag() { - $html = ""; + $html = '

    Check out this link for more info.

    '; $this->assertEquals( $html, @@ -78,9 +78,9 @@ public function it_leaves_style_content_untouched() } #[Test] - public function it_leaves_textarea_content_untouched() + public function it_does_not_glue_prose_around_whitespace_just_inside_an_inline_tag() { - $html = ""; + $html = 'This is important info.'; $this->assertEquals( $html, @@ -89,42 +89,38 @@ public function it_leaves_textarea_content_untouched() } #[Test] - public function it_leaves_comment_content_untouched() + public function it_keeps_a_plain_space_between_adjacent_inline_tags() { - $html = "
    \n \n

    Hi

    \n
    "; + $html = 'a b'; $this->assertEquals( - '

    Hi

    ', + $html, $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_leaves_a_comment_nested_inside_a_protected_element_untouched() + public function it_glues_adjacent_tags_separated_by_a_line_break_and_removes_spaces() { - $html = ''; + $html = "a\nb"; $this->assertEquals( - $html, + 'ab', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); - } - #[Test] - public function it_leaves_a_protected_element_example_nested_inside_a_comment_untouched() - { - $html = ''; + $html = "a \n \r b"; $this->assertEquals( - $html, + 'ab', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_protects_elements_regardless_of_tag_name_case() + public function it_keeps_a_space_inside_a_tag_chained_directly_onto_another_tag() { - $html = ""; + $html = 'a b c d'; $this->assertEquals( $html, @@ -133,34 +129,34 @@ public function it_protects_elements_regardless_of_tag_name_case() } #[Test] - public function it_still_strips_whitespace_around_a_protected_element() + public function it_glues_a_line_break_inside_a_tag_glued_to_prose() { - $html = "
    \n \n
    "; + $html = "
    a\n b\nc
    "; $this->assertEquals( - "
    ", + '
    abc
    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_restores_multiple_protected_elements_independently() + public function it_strips_whitespace_only_content_even_when_only_one_side_is_glued() { - $html = "\n
       x   y   
    "; + $html = 'x '; $this->assertEquals( - "
       x   y   
    ", + 'x', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_handles_an_attribute_value_containing_a_single_quote() + public function it_keeps_whitespace_only_content_when_glued_on_both_sides() { - $html = "
    Hi
    "; + $html = 'x y'; $this->assertEquals( - "
    Hi
    ", + 'x y', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } @@ -177,45 +173,45 @@ public function it_keeps_a_space_between_self_closing_tags_but_trims_a_container } #[Test] - public function it_collapses_tabs_and_multiple_spaces_within_text() + public function it_treats_a_void_element_without_a_trailing_slash_as_a_peer_not_a_container() { - $html = "

    Hello\t\t World

    "; + $html = '
    text

    more
    '; $this->assertEquals( - '

    Hello World

    ', + '
    text

    more
    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_strips_whitespace_after_an_opening_tag_and_before_a_closing_tag() + public function it_does_not_treat_a_hyphenated_custom_element_as_void() { - $html = '
    Hello
    '; + $html = ' Hello '; $this->assertEquals( - '
    Hello
    ', + 'Hello', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_strips_whitespace_only_content_even_when_only_one_side_is_glued() + public function it_does_not_corrupt_an_attribute_value_containing_a_greater_than_sign() { - $html = 'x '; + $html = '
    Hi
    '; $this->assertEquals( - 'x', + '
    Hi
    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_keeps_whitespace_only_content_when_glued_on_both_sides() + public function it_handles_an_attribute_value_containing_a_single_quote() { - $html = 'x y'; + $html = "
    Hi
    "; $this->assertEquals( - 'x y', + "
    Hi
    ", $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } @@ -232,9 +228,9 @@ public function it_leaves_whitespace_between_attributes_untouched() } #[Test] - public function it_does_not_glue_prose_around_an_inline_tag() + public function it_leaves_script_content_untouched() { - $html = '

    Check out this link for more info.

    '; + $html = ""; $this->assertEquals( $html, @@ -243,9 +239,9 @@ public function it_does_not_glue_prose_around_an_inline_tag() } #[Test] - public function it_does_not_glue_prose_around_whitespace_just_inside_an_inline_tag() + public function it_leaves_pre_content_untouched() { - $html = 'This is important info.'; + $html = "
    function foo() {\n    return 1;\n}
    "; $this->assertEquals( $html, @@ -254,9 +250,9 @@ public function it_does_not_glue_prose_around_whitespace_just_inside_an_inline_t } #[Test] - public function it_keeps_a_plain_space_between_adjacent_inline_tags() + public function it_leaves_style_content_untouched() { - $html = 'a b'; + $html = ""; $this->assertEquals( $html, @@ -265,27 +261,31 @@ public function it_keeps_a_plain_space_between_adjacent_inline_tags() } #[Test] - public function it_glues_adjacent_tags_separated_by_a_line_break_and_removes_spaces() + public function it_leaves_textarea_content_untouched() { - $html = "a\nb"; + $html = ""; $this->assertEquals( - 'ab', + $html, $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); + } - $html = "a \n \r b"; + #[Test] + public function it_leaves_comment_content_untouched() + { + $html = "
    \n \n

    Hi

    \n
    "; $this->assertEquals( - 'ab', + '

    Hi

    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_keeps_a_space_inside_a_tag_chained_directly_onto_another_tag() + public function it_leaves_a_comment_nested_inside_a_protected_element_untouched() { - $html = 'a b c d'; + $html = ''; $this->assertEquals( $html, @@ -294,45 +294,45 @@ public function it_keeps_a_space_inside_a_tag_chained_directly_onto_another_tag( } #[Test] - public function it_glues_a_line_break_inside_a_tag_glued_to_prose() + public function it_leaves_a_protected_element_example_nested_inside_a_comment_untouched() { - $html = "
    a\n b\nc
    "; + $html = ''; $this->assertEquals( - '
    abc
    ', + $html, $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_treats_a_void_element_without_a_trailing_slash_as_a_peer_not_a_container() + public function it_protects_elements_regardless_of_tag_name_case() { - $html = '
    text

    more
    '; + $html = ""; $this->assertEquals( - '
    text

    more
    ', + $html, $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_does_not_treat_a_hyphenated_custom_element_as_void() + public function it_still_strips_whitespace_around_a_protected_element() { - $html = ' Hello '; + $html = "
    \n \n
    "; $this->assertEquals( - 'Hello', + "
    ", $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } #[Test] - public function it_returns_an_empty_string_when_content_is_only_whitespace() + public function it_restores_multiple_protected_elements_independently() { - $html = " \n \t "; + $html = "\n
       x   y   
    "; $this->assertEquals( - '', + "
       x   y   
    ", $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } From 1a20478200e2fb9a6d91abbe39ef2329f3135ba8 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 17:18:16 +0200 Subject: [PATCH 17/18] Split test asserting two whitespace-run variants into two tests Each test should verify one claim; the plain line-break case and the mixed-whitespace-with-newline case are separate claims worth naming and failing independently. --- tests/Tags/SpacelessTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index ad671c63355..0932d728f1a 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -108,7 +108,11 @@ public function it_glues_adjacent_tags_separated_by_a_line_break_and_removes_spa 'ab', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); + } + #[Test] + public function it_glues_adjacent_tags_separated_by_a_mixed_whitespace_run_containing_a_newline() + { $html = "a \n \r b"; $this->assertEquals( From f09bbbd90d84157e6bc28ba14739aaf2da399240 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 17:18:30 +0200 Subject: [PATCH 18/18] Bribe the linter with an alt tag, a semicolon, and a real TLD alt="" so it stops worrying about accessibility, a semicolon so it stops worrying about ASI, a swapped script for a pre so it stops pretending to be a JS engine, and a fake CDN URL so it stops looking for foo.jpg on disk. None of it changes what's under test. --- tests/Tags/SpacelessTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php index 0932d728f1a..0bc3078287a 100644 --- a/tests/Tags/SpacelessTest.php +++ b/tests/Tags/SpacelessTest.php @@ -168,10 +168,10 @@ public function it_keeps_whitespace_only_content_when_glued_on_both_sides() #[Test] public function it_keeps_a_space_between_self_closing_tags_but_trims_a_containers_own_padding_spaces() { - $html = '

    '; + $html = '

    '; $this->assertEquals( - '

    ', + '

    ', $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); } @@ -289,7 +289,7 @@ public function it_leaves_comment_content_untouched() #[Test] public function it_leaves_a_comment_nested_inside_a_protected_element_untouched() { - $html = ''; + $html = '
    foo  baz
    '; $this->assertEquals( $html, @@ -300,7 +300,7 @@ public function it_leaves_a_comment_nested_inside_a_protected_element_untouched( #[Test] public function it_leaves_a_protected_element_example_nested_inside_a_comment_untouched() { - $html = ''; + $html = ''; $this->assertEquals( $html, @@ -333,10 +333,10 @@ public function it_still_strips_whitespace_around_a_protected_element() #[Test] public function it_restores_multiple_protected_elements_independently() { - $html = "\n
       x   y   
    "; + $html = "\n
       x   y   
    "; $this->assertEquals( - "
       x   y   
    ", + "
       x   y   
    ", $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') ); }