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, diff --git a/src/Tags/Spaceless.php b/src/Tags/Spaceless.php new file mode 100644 index 00000000000..656d7fd0ca3 --- /dev/null +++ b/src/Tags/Spaceless.php @@ -0,0 +1,187 @@ +parse(); + + $protected = []; + + $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 + ); + } + + 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 + * 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. + $tag = '<\/?[a-zA-Z][^<>"\']*(?:"[^"]*"|\'[^\']*\'|[^<>"\'])*>'; + + preg_match_all('/'.$tag.'/u', $html, $tagMatches, PREG_OFFSET_CAPTURE); + + $tokens = []; + $cursor = 0; + + foreach ($tagMatches[0] as [$tagString, $offset]) { + $this->pushTextTokens(substr($html, $cursor, $offset - $cursor), $tokens); + + $type = match (true) { + str_starts_with($tagString, ' 'closing', + $this->hasNoClosingTag($tagString) => 'void', + default => 'opening', + }; + + $tokens[] = ['type' => $type, 'value' => $tagString]; + $cursor = $offset + strlen($tagString); + } + + $this->pushTextTokens(substr($html, $cursor), $tokens); + + return $tokens; + } + + 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]]; + } + } + + /** + * 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) { + if ($token['type'] !== 'ws') { + $html .= $token['value']; + + continue; + } + + $html .= $this->shouldKeepWhitespace($tokens, $i) ? ' ' : ''; + } + + return trim($html); + } + + private function shouldKeepWhitespace(array $tokens, int $i): bool + { + $hasNewline = str_contains($tokens[$i]['value'], "\n"); + $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 + // outside it (e.g. `is important`). + return ! $hasNewline && $this->externallyGlued($tokens, $i - 1, -1); + } + + 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; + } + + /** + * 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 + { + $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); + } +} diff --git a/tests/Tags/SpacelessTest.php b/tests/Tags/SpacelessTest.php new file mode 100644 index 00000000000..0bc3078287a --- /dev/null +++ b/tests/Tags/SpacelessTest.php @@ -0,0 +1,348 @@ + +
  • 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_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_returns_an_empty_string_when_content_is_only_whitespace() + { + $html = " \n \t "; + + $this->assertEquals( + '', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[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_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_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 }}') + ); + } + + #[Test] + public function it_glues_adjacent_tags_separated_by_a_mixed_whitespace_run_containing_a_newline() + { + $html = "a \n \r b"; + + $this->assertEquals( + 'ab', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[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_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_keeps_a_space_between_self_closing_tags_but_trims_a_containers_own_padding_spaces() + { + $html = '

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

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[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_does_not_treat_a_hyphenated_custom_element_as_void() + { + $html = ' Hello '; + + $this->assertEquals( + 'Hello', + $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_handles_an_attribute_value_containing_a_single_quote() + { + $html = "
    Hi
    "; + + $this->assertEquals( + "
    Hi
    ", + $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_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 }}') + ); + } + + #[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_leaves_comment_content_untouched() + { + $html = "
    \n \n

    Hi

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

    Hi

    ', + $this->tag('{{ spaceless }}'.$html.'{{ /spaceless }}') + ); + } + + #[Test] + public function it_leaves_a_comment_nested_inside_a_protected_element_untouched() + { + $html = '
    foo  baz
    '; + + $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() + { + $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 }}') + ); + } + + private function tag($tag): string + { + return (string) Parse::template(str: $tag, trusted: true); + } +}