From 399173f69a66207265298aea8c1c36de73d0b7e4 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 15:28:13 +0200 Subject: [PATCH 1/7] added support for tags with raw body --- src/Parse/Lexer.php | 87 +++++++++++++------ src/Parse/LexerOptions.php | 23 ++--- src/TagBlock.php | 5 ++ .../Integration/Tags/InlineCommentTagTest.php | 2 +- 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index abe2e3c..68d2cb1 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -3,6 +3,7 @@ namespace Keepsuit\Liquid\Parse; use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\TagBlock; use RuntimeException; class Lexer @@ -36,6 +37,11 @@ class Lexer protected int $position; + /** + * @var string[] + */ + protected array $rawBodyTags; + public function __construct( protected ParseContext $parseContext, ) {} @@ -53,6 +59,14 @@ public function tokenize(string $source): TokenStream $this->state = LexerState::Data; $this->tokens = []; + $this->rawBodyTags = array_keys(array_filter($this->parseContext->environment->tagRegistry->all(), function ($tag) { + if (! is_subclass_of($tag, TagBlock::class)) { + return false; + } + + return $tag::hasRawBody(); + })); + $this->parseContext->lineNumber = 1; preg_match_all(LexerOptions::tokenStartRegex(), $this->source, $matches, PREG_OFFSET_CAPTURE); @@ -159,18 +173,41 @@ protected function lexVariable(): void */ protected function lexBlock(): void { - if (preg_match(LexerOptions::blockEndRegex(), $this->source, $matches, offset: $this->cursor) === 1) { - $this->pushToken(TokenType::BlockEnd); - $this->moveCursor($matches[0]); - $this->popState(); + $tag = null; - // trim? - if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { - preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); - $this->moveCursor($matches[0] ?? ''); + // Parse the full expression inside {% ... %} + while (preg_match(LexerOptions::blockEndRegex(), $this->source, $matches, offset: $this->cursor) !== 1) { + $this->lexExpression(); + + $lastToken = $this->tokens[array_key_last($this->tokens)]; + + if ($tag === null && $lastToken->type === TokenType::Identifier) { + $tag = $lastToken; } + } + + // Move the cursor to the end of the block + $this->moveCursor($matches[0]); + + // trim? + if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { + preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); + $this->moveCursor($matches[0] ?? ''); + } + + // If the last token is a block start, we remove the node + $lastToken = $this->tokens[array_key_last($this->tokens)]; + if ($lastToken->type === TokenType::BlockStart) { + array_pop($this->tokens); } else { - $this->lexExpression(); + $this->pushToken(TokenType::BlockEnd); + } + + $this->popState(); + + // If the tag is a raw body tag, we need to lex the body as raw data instead of liquid blocks + if ($tag !== null && in_array($tag->data, $this->rawBodyTags, true)) { + $this->laxRawBodyTag($tag->data); } } @@ -227,6 +264,19 @@ protected function ensureStreamNotEnded(): void } } + private function laxRawBodyTag(string $tag): void + { + if (preg_match(LexerOptions::blockRawBodyTagDataRegex($tag), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { + throw SyntaxException::tagBlockNeverClosed($tag); + } + + $rawBody = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); + + $this->moveCursor($rawBody); + + $this->pushToken(TokenType::RawData, $rawBody); + } + protected function lexRawData(): void { if (preg_match(LexerOptions::blockRawDataRegex(), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { @@ -265,24 +315,7 @@ protected function lexInlineComment(): void $text = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); - $this->moveCursor($text.$matches[0][0]); - - if ($matches[1][0] === "\n") { - return; - } - - $lastToken = $this->tokens[count($this->tokens) - 1] ?? null; - - if ($lastToken?->type === TokenType::BlockStart) { - array_pop($this->tokens); - } else { - $this->pushToken(TokenType::BlockEnd); - } - - if ($matches[1][0] === LexerOptions::WhitespaceTrim->value) { - preg_match('/\s+/A', $this->source, $matches2, offset: $this->cursor); - $this->moveCursor($matches2[0] ?? ''); - } + $this->moveCursor($text); } protected function pushToken(TokenType $type, string $value = ''): void diff --git a/src/Parse/LexerOptions.php b/src/Parse/LexerOptions.php index e57e012..2591b02 100644 --- a/src/Parse/LexerOptions.php +++ b/src/Parse/LexerOptions.php @@ -33,44 +33,47 @@ public static function tokenStartRegex(): string return $regex; } - public static function commentBlockRegex(): string + public static function variableEndRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( - "{\s*comment\s*(?:%s|%s')}Asx", - preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), - preg_quote(LexerOptions::TagBlockEnd->value), + '{\s*(?:%s|%s)}Ax', + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), + preg_quote(LexerOptions::TagVariableEnd->value), ); } return $regex; } - public static function variableEndRegex(): string + public static function blockEndRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( '{\s*(?:%s|%s)}Ax', - preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), - preg_quote(LexerOptions::TagVariableEnd->value), + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::TagBlockEnd->value), ); } return $regex; } - public static function blockEndRegex(): string + public static function blockRawBodyTagDataRegex(string $tag): string { static $regex; if ($regex === null) { $regex = sprintf( - '{\s*(?:%s|%s)}Ax', - preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), + '{%s(%s)?\s*end%s\s*(%s)?%s}sx', + preg_quote(LexerOptions::TagBlockStart->value), + LexerOptions::WhitespaceTrim->value, + preg_quote($tag), + LexerOptions::WhitespaceTrim->value, preg_quote(LexerOptions::TagBlockEnd->value), ); } diff --git a/src/TagBlock.php b/src/TagBlock.php index 32254ba..8bfecff 100644 --- a/src/TagBlock.php +++ b/src/TagBlock.php @@ -20,4 +20,9 @@ public function parseTreeVisitorChildren(): array { return []; } + + public static function hasRawBody(): bool + { + return false; + } } diff --git a/tests/Integration/Tags/InlineCommentTagTest.php b/tests/Integration/Tags/InlineCommentTagTest.php index e048d9a..48266ef 100644 --- a/tests/Integration/Tags/InlineCommentTagTest.php +++ b/tests/Integration/Tags/InlineCommentTagTest.php @@ -51,5 +51,5 @@ }); test('inline comment does not support nested tags', function () { - assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected token type: %}', "{%- # {% echo 'hello world' %} -%}"); + assertTemplateResult(' -%}', "{%- # {% echo 'hello world' %} -%}"); }); From 102b49692aee9757792b956ca8479da92f2cd18c Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 15:28:48 +0200 Subject: [PATCH 2/7] added doc tag --- src/Extensions/StandardExtension.php | 1 + src/Tags/DocTag.php | 57 +++++++++++ tests/Integration/Tags/DocTagTest.php | 142 ++++++++++++++++++++++++++ tests/Unit/BlockTest.php | 11 ++ tests/Unit/EnvironmentTest.php | 3 +- tests/Unit/ParseTreeVisitorTest.php | 4 + 6 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/Tags/DocTag.php create mode 100644 tests/Integration/Tags/DocTagTest.php diff --git a/src/Extensions/StandardExtension.php b/src/Extensions/StandardExtension.php index f5c6245..799b49b 100644 --- a/src/Extensions/StandardExtension.php +++ b/src/Extensions/StandardExtension.php @@ -17,6 +17,7 @@ public function getTags(): array Tags\ContinueTag::class, Tags\CycleTag::class, Tags\DecrementTag::class, + Tags\DocTag::class, Tags\EchoTag::class, Tags\ForTag::class, Tags\IfChanged::class, diff --git a/src/Tags/DocTag.php b/src/Tags/DocTag.php new file mode 100644 index 0000000..d22a8db --- /dev/null +++ b/src/Tags/DocTag.php @@ -0,0 +1,57 @@ +params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('doc tag must have a single raw body'), + }; + + $this->ensureNoNestedDocTags(); + + return $this; + } + + public function render(RenderContext $context): string + { + return ''; + } + + /** + * @throws SyntaxException + */ + protected function ensureNoNestedDocTags(): void + { + if (preg_match('/{%-?\s*doc\s*-?%}/', $this->body->value) === 1) { + throw new SyntaxException('Nested doc tags are not allowed'); + } + } +} diff --git a/tests/Integration/Tags/DocTagTest.php b/tests/Integration/Tags/DocTagTest.php new file mode 100644 index 0000000..392bbcb --- /dev/null +++ b/tests/Integration/Tags/DocTagTest.php @@ -0,0 +1,142 @@ + new \Keepsuit\Liquid\Tests\Stubs\ErrorDrop], + renderErrors: true + ); +}); + +test('doc tag whitespace control', function () { + assertTemplateResult('Hello!', ' {%- doc -%}123{%- enddoc -%}Hello!'); + assertTemplateResult('Hello!', '{%- doc -%}123{%- enddoc -%} Hello!'); + assertTemplateResult('Hello!', ' {%- doc -%}123{%- enddoc -%} Hello!'); + assertTemplateResult('Hello!', <<<'LIQUID' + {%- doc %}Whitespace control!{% enddoc -%} + Hello! + LIQUID); +}); + +test('doc tag delimiter handling', function () { + assertTemplateResult('', <<<'LIQUID' + {% if true -%} + {% doc %} + {% docEXTRA %}wut{% enddocEXTRA %}xyz + {% enddoc %} + {%- endif %} + LIQUID); + assertMatchSyntaxError("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %}123{% enddoc xyz %}'); + assertTemplateResult('', "{% doc %}123{% enddoc\n xyz %}{% enddoc %}"); +}); diff --git a/tests/Unit/BlockTest.php b/tests/Unit/BlockTest.php index 74db631..9192cfb 100644 --- a/tests/Unit/BlockTest.php +++ b/tests/Unit/BlockTest.php @@ -2,6 +2,7 @@ use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Tags\DocTag; use Keepsuit\Liquid\Tags\IfTag; test('blankspace', function () { @@ -66,3 +67,13 @@ ->{1}->children()->{0}->children()->{0}->toBeInstanceOf(Text::class) ->{2}->toBeInstanceOf(Text::class); }); + +test('doc tag with block', function () { + $template = parseTemplate(" {% doc %} {% enddoc %} "); + + expect($template->root->body->children()) + ->toHaveCount(3) + ->{0}->toBeInstanceOf(Text::class) + ->{1}->toBeInstanceOf(DocTag::class) + ->{2}->toBeInstanceOf(Text::class); +}); diff --git a/tests/Unit/EnvironmentTest.php b/tests/Unit/EnvironmentTest.php index 8a82387..8f1b043 100644 --- a/tests/Unit/EnvironmentTest.php +++ b/tests/Unit/EnvironmentTest.php @@ -15,13 +15,14 @@ $tags = $env->tagRegistry->all(); - expect($tags)->toHaveCount(16) + expect($tags)->toHaveCount(17) ->toHaveKey('assign') ->toHaveKey('break') ->toHaveKey('capture') ->toHaveKey('case') ->toHaveKey('cycle') ->toHaveKey('decrement') + ->toHaveKey('doc') ->toHaveKey('echo') ->toHaveKey('for') ->toHaveKey('ifchanged') diff --git a/tests/Unit/ParseTreeVisitorTest.php b/tests/Unit/ParseTreeVisitorTest.php index fbe2813..0329194 100644 --- a/tests/Unit/ParseTreeVisitorTest.php +++ b/tests/Unit/ParseTreeVisitorTest.php @@ -152,6 +152,10 @@ ]); }); +test('doc', function () { + expect(visit('{% doc %}{{ test }}{% enddoc %}'))->toBe([]); +}); + function traversal(string $source): ParseTreeVisitor { $environment = EnvironmentFactory::new() From 07974fc12ef2859ffb1484ce5a0c0629327b0ac5 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 16:58:56 +0200 Subject: [PATCH 3/7] added raw tag instead of forced raw node --- src/Extensions/StandardExtension.php | 1 + src/Parse/Lexer.php | 71 ++++++++++++++------------- src/Parse/LexerOptions.php | 54 +++++++------------- src/Tags/RawTag.php | 45 +++++++++++++++++ tests/Integration/Tags/RawTagTest.php | 5 +- tests/Unit/BlockTest.php | 2 +- tests/Unit/EnvironmentTest.php | 3 +- 7 files changed, 106 insertions(+), 75 deletions(-) create mode 100644 src/Tags/RawTag.php diff --git a/src/Extensions/StandardExtension.php b/src/Extensions/StandardExtension.php index 799b49b..88f4c11 100644 --- a/src/Extensions/StandardExtension.php +++ b/src/Extensions/StandardExtension.php @@ -24,6 +24,7 @@ public function getTags(): array Tags\IfTag::class, Tags\IncrementTag::class, Tags\LiquidTag::class, + Tags\RawTag::class, Tags\RenderTag::class, Tags\TableRowTag::class, Tags\UnlessTag::class, diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 68d2cb1..775879f 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -31,7 +31,7 @@ class Lexer protected array $tokens; /** - * @var array> + * @var array */ protected array $positions; @@ -69,8 +69,7 @@ public function tokenize(string $source): TokenStream $this->parseContext->lineNumber = 1; - preg_match_all(LexerOptions::tokenStartRegex(), $this->source, $matches, PREG_OFFSET_CAPTURE); - $this->positions = $matches; + $this->positions = $this->extractTokenStarts($this->source); $this->position = -1; while ($this->cursor < $this->end) { @@ -93,7 +92,7 @@ public function tokenize(string $source): TokenStream protected function lexData(): void { // if no matches are left we return the rest of the template as simple text token - if ($this->position == count($this->positions[0]) - 1) { + if ($this->position == count($this->positions) - 1) { $this->pushToken(TokenType::TextData, substr($this->source, $this->cursor)); $this->cursor = $this->end; @@ -101,34 +100,27 @@ protected function lexData(): void } // Find the first token after the current cursor - $position = $this->positions[0][++$this->position]; + $position = $this->positions[++$this->position]; while ($position[1] < $this->cursor) { - if ($this->position == count($this->positions[0]) - 1) { + if ($this->position == count($this->positions) - 1) { return; } - $position = $this->positions[0][++$this->position]; + $position = $this->positions[++$this->position]; } // push the template text before the token first $text = $textBeforeToken = substr($this->source, $this->cursor, $position[1] - $this->cursor); // trim? - if ($this->positions[2][$this->position][0] === LexerOptions::WhitespaceTrim->value) { + if (($this->positions[$this->position][0][2] ?? null) === LexerOptions::WhitespaceTrim->value) { $textBeforeToken = rtrim($textBeforeToken); } $this->pushToken(TokenType::TextData, $textBeforeToken); $this->moveCursor($text.$position[0]); - switch ($this->positions[1][$this->position][0]) { + switch (rtrim($this->positions[$this->position][0], LexerOptions::WhitespaceTrim->value)) { case LexerOptions::TagBlockStart->value: - // {% raw %} - if (preg_match(LexerOptions::blockRawStartRegex(), $this->source, $matches, offset: $this->cursor) === 1) { - $this->moveCursor($matches[0]); - $this->lexRawData(); - break; - } - // {% comment %} if (preg_match(LexerOptions::blockCommentStartRegex(), $this->source, $matches, offset: $this->cursor) === 1) { $this->moveCursor($matches[0]); @@ -160,8 +152,7 @@ protected function lexVariable(): void // trim? if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { - preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); - $this->moveCursor($matches[0] ?? ''); + $this->trimWhitespaces(); } } else { $this->lexExpression(); @@ -191,8 +182,7 @@ protected function lexBlock(): void // trim? if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { - preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); - $this->moveCursor($matches[0] ?? ''); + $this->trimWhitespaces(); } // If the last token is a block start, we remove the node @@ -274,26 +264,17 @@ private function laxRawBodyTag(string $tag): void $this->moveCursor($rawBody); - $this->pushToken(TokenType::RawData, $rawBody); - } - - protected function lexRawData(): void - { - if (preg_match(LexerOptions::blockRawDataRegex(), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { - throw SyntaxException::tagBlockNeverClosed('raw'); + // inner trim? + if (($matches[1][0][2] ?? null) === LexerOptions::WhitespaceTrim->value) { + $rawBody = rtrim($rawBody); } - $text = substr($this->source, $this->cursor, $matches[0][1] - $this->cursor); - - $this->moveCursor($text.$matches[0][0]); + $this->pushToken(TokenType::RawData, $rawBody); // trim? - if (isset($matches[2][0])) { - preg_match('/\s+/A', $this->source, $matches2, offset: $this->cursor); - $this->moveCursor($matches2[0] ?? ''); + if ($matches[2][0][0] === LexerOptions::WhitespaceTrim->value) { + $this->trimWhitespaces(); } - - $this->pushToken(TokenType::RawData, $text); } protected function lexComment(): void @@ -355,4 +336,24 @@ protected function popState(): void $this->state = $state; } + + protected function trimWhitespaces(): void + { + preg_match('/\s+/A', $this->source, $matches, offset: $this->cursor); + $this->moveCursor($matches[0] ?? ''); + } + + /** + * @return array + */ + protected function extractTokenStarts(string $source): array + { + preg_match_all(LexerOptions::blockStartRegex(), $source, $blocks, PREG_OFFSET_CAPTURE); + preg_match_all(LexerOptions::variableStartRegex(), $source, $variables, PREG_OFFSET_CAPTURE); + + $positions = array_merge($blocks[0], $variables[0]); + usort($positions, fn (array $a, array $b) => $a[1] <=> $b[1]); + + return $positions; + } } diff --git a/src/Parse/LexerOptions.php b/src/Parse/LexerOptions.php index 2591b02..68a1dcf 100644 --- a/src/Parse/LexerOptions.php +++ b/src/Parse/LexerOptions.php @@ -17,14 +17,13 @@ enum LexerOptions: string case WhitespaceTrim = '-'; - public static function tokenStartRegex(): string + public static function blockStartRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( - '{(%s|%s)(%s)?}sx', - preg_quote(LexerOptions::TagVariableStart->value), + '{(%s%s?)}sx', preg_quote(LexerOptions::TagBlockStart->value), preg_quote(LexerOptions::WhitespaceTrim->value) ); @@ -33,61 +32,43 @@ public static function tokenStartRegex(): string return $regex; } - public static function variableEndRegex(): string + public static function variableStartRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( - '{\s*(?:%s|%s)}Ax', - preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), - preg_quote(LexerOptions::TagVariableEnd->value), + '{(%s%s?)}sx', + preg_quote(LexerOptions::TagVariableStart->value), + preg_quote(LexerOptions::WhitespaceTrim->value) ); } return $regex; } - public static function blockEndRegex(): string + public static function variableEndRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( '{\s*(?:%s|%s)}Ax', - preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), - preg_quote(LexerOptions::TagBlockEnd->value), - ); - } - - return $regex; - } - - public static function blockRawBodyTagDataRegex(string $tag): string - { - static $regex; - - if ($regex === null) { - $regex = sprintf( - '{%s(%s)?\s*end%s\s*(%s)?%s}sx', - preg_quote(LexerOptions::TagBlockStart->value), - LexerOptions::WhitespaceTrim->value, - preg_quote($tag), - LexerOptions::WhitespaceTrim->value, - preg_quote(LexerOptions::TagBlockEnd->value), + preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), + preg_quote(LexerOptions::TagVariableEnd->value), ); } return $regex; } - public static function blockRawStartRegex(): string + public static function blockEndRegex(): string { static $regex; if ($regex === null) { $regex = sprintf( - '{\s*raw\s*(?:%s|%s)}Ax', + '{\s*(?:%s|%s)}Ax', preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), preg_quote(LexerOptions::TagBlockEnd->value), ); @@ -96,21 +77,22 @@ public static function blockRawStartRegex(): string return $regex; } - public static function blockRawDataRegex(): string + public static function blockRawBodyTagDataRegex(string $tag): string { - static $regex; + static $regex = []; - if ($regex === null) { - $regex = sprintf( - '{%s(%s)?\s*endraw\s*(%s)?%s}sx', + if (($regex[$tag] ?? null) === null) { + $regex[$tag] = sprintf( + '{(%s%s?)\s*end%s\s*(%s?%s)}sx', preg_quote(LexerOptions::TagBlockStart->value), LexerOptions::WhitespaceTrim->value, + preg_quote($tag), LexerOptions::WhitespaceTrim->value, preg_quote(LexerOptions::TagBlockEnd->value), ); } - return $regex; + return $regex[$tag]; } public static function blockCommentStartRegex(): string diff --git a/src/Tags/RawTag.php b/src/Tags/RawTag.php new file mode 100644 index 0000000..a44d1d8 --- /dev/null +++ b/src/Tags/RawTag.php @@ -0,0 +1,45 @@ +params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('raw tag must have a single raw body'), + }; + + return $this; + } + + public function render(RenderContext $context): string + { + return $this->body->render($context); + } +} diff --git a/tests/Integration/Tags/RawTagTest.php b/tests/Integration/Tags/RawTagTest.php index 1379828..1d10331 100644 --- a/tests/Integration/Tags/RawTagTest.php +++ b/tests/Integration/Tags/RawTagTest.php @@ -9,8 +9,9 @@ test('output in raw', function () { assertTemplateResult('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <'); - assertTemplateResult('> inner <', '> {%- raw -%} inner {%- endraw %} <'); - assertTemplateResult('> inner <', '> {%- raw -%} inner {%- endraw -%} <'); + assertTemplateResult('>inner <', '> {%- raw -%} inner {%- endraw %} <'); + assertTemplateResult('>inner<', '> {%- raw -%} inner {%- endraw -%} <'); + assertTemplateResult('{Hello}', '{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}'); }); test('open tag in raw', function () { diff --git a/tests/Unit/BlockTest.php b/tests/Unit/BlockTest.php index 9192cfb..2004167 100644 --- a/tests/Unit/BlockTest.php +++ b/tests/Unit/BlockTest.php @@ -69,7 +69,7 @@ }); test('doc tag with block', function () { - $template = parseTemplate(" {% doc %} {% enddoc %} "); + $template = parseTemplate(' {% doc %} {% enddoc %} '); expect($template->root->body->children()) ->toHaveCount(3) diff --git a/tests/Unit/EnvironmentTest.php b/tests/Unit/EnvironmentTest.php index 8f1b043..e71cf22 100644 --- a/tests/Unit/EnvironmentTest.php +++ b/tests/Unit/EnvironmentTest.php @@ -15,7 +15,7 @@ $tags = $env->tagRegistry->all(); - expect($tags)->toHaveCount(17) + expect($tags)->toHaveCount(18) ->toHaveKey('assign') ->toHaveKey('break') ->toHaveKey('capture') @@ -29,6 +29,7 @@ ->toHaveKey('if') ->toHaveKey('increment') ->toHaveKey('liquid') + ->toHaveKey('raw') ->toHaveKey('render') ->toHaveKey('tablerow') ->toHaveKey('unless'); From 13459bef0c7e67d08ea4560feaaee0ce27285911 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 17:13:17 +0200 Subject: [PATCH 4/7] removed rtrim --- src/Parse/Lexer.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 775879f..1359304 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -119,8 +119,9 @@ protected function lexData(): void $this->pushToken(TokenType::TextData, $textBeforeToken); $this->moveCursor($text.$position[0]); - switch (rtrim($this->positions[$this->position][0], LexerOptions::WhitespaceTrim->value)) { + switch ($this->positions[$this->position][0]) { case LexerOptions::TagBlockStart->value: + case LexerOptions::TagBlockStart->value.LexerOptions::WhitespaceTrim->value: // {% comment %} if (preg_match(LexerOptions::blockCommentStartRegex(), $this->source, $matches, offset: $this->cursor) === 1) { $this->moveCursor($matches[0]); @@ -133,6 +134,7 @@ protected function lexData(): void $this->currentVarBlockLine = $this->lineNumber; break; case LexerOptions::TagVariableStart->value: + case LexerOptions::TagVariableStart->value.LexerOptions::WhitespaceTrim->value: $this->pushToken(TokenType::VariableStart); $this->pushState(LexerState::Variable); $this->currentVarBlockLine = $this->lineNumber; From cba628f0e3bed231dd2d140412538de962888dc4 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 17:22:37 +0200 Subject: [PATCH 5/7] use captured block instead of trimming full match --- src/Parse/Lexer.php | 4 ++-- src/Parse/LexerOptions.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 1359304..69b4715 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -153,7 +153,7 @@ protected function lexVariable(): void $this->popState(); // trim? - if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { + if ($matches[1][0] === LexerOptions::WhitespaceTrim->value) { $this->trimWhitespaces(); } } else { @@ -183,7 +183,7 @@ protected function lexBlock(): void $this->moveCursor($matches[0]); // trim? - if (trim($matches[0])[0] === LexerOptions::WhitespaceTrim->value) { + if ($matches[1][0] === LexerOptions::WhitespaceTrim->value) { $this->trimWhitespaces(); } diff --git a/src/Parse/LexerOptions.php b/src/Parse/LexerOptions.php index 68a1dcf..62b11ff 100644 --- a/src/Parse/LexerOptions.php +++ b/src/Parse/LexerOptions.php @@ -53,7 +53,7 @@ public static function variableEndRegex(): string if ($regex === null) { $regex = sprintf( - '{\s*(?:%s|%s)}Ax', + '{\s*(%s|%s)}Ax', preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagVariableEnd->value), preg_quote(LexerOptions::TagVariableEnd->value), ); @@ -68,7 +68,7 @@ public static function blockEndRegex(): string if ($regex === null) { $regex = sprintf( - '{\s*(?:%s|%s)}Ax', + '{\s*(%s|%s)}Ax', preg_quote(LexerOptions::WhitespaceTrim->value.LexerOptions::TagBlockEnd->value), preg_quote(LexerOptions::TagBlockEnd->value), ); From 38ffcbf7bfd145a46ee566106ced0fde235a8fa5 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 25 Apr 2025 17:27:51 +0200 Subject: [PATCH 6/7] fix visibility --- src/Parse/Lexer.php | 2 +- src/Tags/DocTag.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 69b4715..c8fb57e 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -256,7 +256,7 @@ protected function ensureStreamNotEnded(): void } } - private function laxRawBodyTag(string $tag): void + protected function laxRawBodyTag(string $tag): void { if (preg_match(LexerOptions::blockRawBodyTagDataRegex($tag), $this->source, $matches, flags: PREG_OFFSET_CAPTURE, offset: $this->cursor) !== 1) { throw SyntaxException::tagBlockNeverClosed($tag); diff --git a/src/Tags/DocTag.php b/src/Tags/DocTag.php index d22a8db..938e23e 100644 --- a/src/Tags/DocTag.php +++ b/src/Tags/DocTag.php @@ -11,7 +11,7 @@ class DocTag extends TagBlock { - protected Raw $body; + public readonly Raw $body; public static function tagName(): string { From 1ea8f87b4fd954e4d890acb770891e9a91ff3224 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sun, 15 Jun 2025 13:42:15 +0200 Subject: [PATCH 7/7] body access --- src/Tags/DocTag.php | 7 ++++++- src/Tags/RawTag.php | 5 +++++ tests/Integration/Tags/DocTagTest.php | 20 ++++++++++++++++++++ tests/Integration/Tags/RawTagTest.php | 22 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/Tags/DocTag.php b/src/Tags/DocTag.php index 938e23e..817dcd9 100644 --- a/src/Tags/DocTag.php +++ b/src/Tags/DocTag.php @@ -11,7 +11,7 @@ class DocTag extends TagBlock { - public readonly Raw $body; + protected Raw $body; public static function tagName(): string { @@ -54,4 +54,9 @@ protected function ensureNoNestedDocTags(): void throw new SyntaxException('Nested doc tags are not allowed'); } } + + public function getBody(): Raw + { + return $this->body; + } } diff --git a/src/Tags/RawTag.php b/src/Tags/RawTag.php index a44d1d8..d3eabe2 100644 --- a/src/Tags/RawTag.php +++ b/src/Tags/RawTag.php @@ -42,4 +42,9 @@ public function render(RenderContext $context): string { return $this->body->render($context); } + + public function getBody(): Raw + { + return $this->body; + } } diff --git a/tests/Integration/Tags/DocTagTest.php b/tests/Integration/Tags/DocTagTest.php index 392bbcb..95dd6db 100644 --- a/tests/Integration/Tags/DocTagTest.php +++ b/tests/Integration/Tags/DocTagTest.php @@ -140,3 +140,23 @@ assertMatchSyntaxError("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %}123{% enddoc xyz %}'); assertTemplateResult('', "{% doc %}123{% enddoc\n xyz %}{% enddoc %}"); }); + +test('access doc tag body', function () { + $content = <<<'EOF' + Renders loading-spinner. + @param {string} foo - some foo + @param {string} [bar] - optional bar + EOF; + + $template = <<root->body->children()[0] ?? null; + + expect($docTag) + ->toBeInstanceOf(\Keepsuit\Liquid\Tags\DocTag::class) + ->getBody()->toBeInstanceOf(\Keepsuit\Liquid\Nodes\Raw::class) + ->getBody()->value->toBe($content); +}); diff --git a/tests/Integration/Tags/RawTagTest.php b/tests/Integration/Tags/RawTagTest.php index 1d10331..829b2c9 100644 --- a/tests/Integration/Tags/RawTagTest.php +++ b/tests/Integration/Tags/RawTagTest.php @@ -31,3 +31,25 @@ assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected character }', '{% raw } foo {% endraw %}'); assertMatchSyntaxError('Liquid syntax error (line 1): Unexpected character }', '{% raw } foo %}{% endraw %}'); }); + +test('access raw tag body', function () { + $content = <<<'EOF' + {% if true %} + true + {% else %} + false + {% endif %} + EOF; + + $template = <<root->body->children()[0] ?? null; + + expect($rawTag) + ->toBeInstanceOf(\Keepsuit\Liquid\Tags\RawTag::class) + ->getBody()->toBeInstanceOf(\Keepsuit\Liquid\Nodes\Raw::class) + ->getBody()->value->toBe($content); +});