diff --git a/src/Latte/Compiler/Escaper.php b/src/Latte/Compiler/Escaper.php index 4a613df1e..0b2c7011b 100644 --- a/src/Latte/Compiler/Escaper.php +++ b/src/Latte/Compiler/Escaper.php @@ -40,7 +40,6 @@ final class Escaper private string $state = ''; private string $tag = ''; - private string $quote = ''; private string $subType = ''; @@ -65,8 +64,7 @@ public function getState(): string public function export(): string { - return ($this->state === self::HtmlAttribute && $this->quote === '' ? 'html/unquoted-attr' : $this->state) - . ($this->subType ? '/' . $this->subType : ''); + return $this->state . ($this->subType ? '/' . $this->subType : ''); } @@ -101,10 +99,9 @@ public function enterHtmlTag(string $name): void } - public function enterHtmlAttribute(?string $name = null, string $quote = ''): void + public function enterHtmlAttribute(?string $name = null): void { $this->state = self::HtmlAttribute; - $this->quote = $quote; $this->subType = ''; if ($this->contentType === ContentType::Html && is_string($name)) { @@ -122,12 +119,6 @@ public function enterHtmlAttribute(?string $name = null, string $quote = ''): vo } - public function enterHtmlAttributeQuote(string $quote = '"'): void - { - $this->quote = $quote; - } - - public function enterHtmlBogusTag(): void { $this->state = self::HtmlBogusTag; @@ -142,16 +133,15 @@ public function enterHtmlComment(): void public function escape(string $str): string { - [$lq, $rq] = $this->state === self::HtmlAttribute && !$this->quote ? ["'\"' . ", " . '\"'"] : ['', '']; return match ($this->contentType) { ContentType::Html => match ($this->state) { self::HtmlText => 'LR\Filters::escapeHtmlText(' . $str . ')', self::HtmlTag => 'LR\Filters::escapeHtmlTag(' . $str . ')', self::HtmlAttribute => match ($this->subType) { '', - self::Url => $lq . 'LR\Filters::escapeHtmlAttr(' . $str . ')' . $rq, - self::JavaScript => $lq . 'LR\Filters::escapeHtmlAttr(LR\Filters::escapeJs(' . $str . '))' . $rq, - self::Css => $lq . 'LR\Filters::escapeHtmlAttr(LR\Filters::escapeCss(' . $str . '))' . $rq, + self::Url => 'LR\Filters::escapeHtmlAttr(' . $str . ')', + self::JavaScript => 'LR\Filters::escapeHtmlAttr(LR\Filters::escapeJs(' . $str . '))', + self::Css => 'LR\Filters::escapeHtmlAttr(LR\Filters::escapeCss(' . $str . '))', }, self::HtmlComment => 'LR\Filters::escapeHtmlComment(' . $str . ')', self::HtmlBogusTag => 'LR\Filters::escapeHtml(' . $str . ')', @@ -162,7 +152,7 @@ public function escape(string $str): string ContentType::Xml => match ($this->state) { self::HtmlText, self::HtmlBogusTag => 'LR\Filters::escapeXml(' . $str . ')', - self::HtmlAttribute => $lq . 'LR\Filters::escapeXml(' . $str . ')' . $rq, + self::HtmlAttribute => 'LR\Filters::escapeXml(' . $str . ')', self::HtmlComment => 'LR\Filters::escapeHtmlComment(' . $str . ')', self::HtmlTag => 'LR\Filters::escapeXmlTag(' . $str . ')', default => throw new \LogicException("Unknown context $this->contentType, $this->state."), @@ -218,19 +208,14 @@ public static function getConvertor(string $source, string $dest): ?callable 'html/attr/css' => 'convertHtmlToHtmlAttr', 'html/attr/url' => 'convertHtmlToHtmlAttr', 'html/comment' => 'escapeHtmlComment', - 'html/unquoted-attr' => 'convertHtmlToUnquotedAttr', ], 'html/attr' => [ 'html' => 'convertHtmlToHtmlAttr', - 'html/unquoted-attr' => 'convertHtmlAttrToUnquotedAttr', ], 'html/attr/url' => [ 'html' => 'convertHtmlToHtmlAttr', 'html/attr' => 'nop', ], - 'html/unquoted-attr' => [ - 'html' => 'convertHtmlToHtmlAttr', - ], ]; if ($source === $dest) { diff --git a/src/Latte/Compiler/NodeHelpers.php b/src/Latte/Compiler/NodeHelpers.php index 52917f022..55433a5c6 100644 --- a/src/Latte/Compiler/NodeHelpers.php +++ b/src/Latte/Compiler/NodeHelpers.php @@ -124,7 +124,6 @@ public static function toText(?Node $node): ?string return match (true) { $node instanceof Nodes\TextNode => $node->content, - $node instanceof Nodes\Html\QuotedValue => self::toText($node->value), $node instanceof Nodes\NopNode => '', default => null, }; diff --git a/src/Latte/Compiler/Nodes/Html/AttributeNode.php b/src/Latte/Compiler/Nodes/Html/AttributeNode.php index e8cf920b8..53fb8a468 100644 --- a/src/Latte/Compiler/Nodes/Html/AttributeNode.php +++ b/src/Latte/Compiler/Nodes/Html/AttributeNode.php @@ -11,6 +11,7 @@ use Latte\Compiler\NodeHelpers; use Latte\Compiler\Nodes\AreaNode; +use Latte\Compiler\Nodes\FragmentNode; use Latte\Compiler\Position; use Latte\Compiler\PrintContext; @@ -20,6 +21,7 @@ class AttributeNode extends AreaNode public function __construct( public AreaNode $name, public ?AreaNode $value = null, + public ?string $quote = null, public ?Position $position = null, ) { } @@ -29,9 +31,19 @@ public function print(PrintContext $context): string { $res = $this->name->print($context); if ($this->value) { - $context->beginEscape()->enterHtmlAttribute(NodeHelpers::toText($this->name)); $res .= "echo '=';"; - $res .= $this->value->print($context); + $res .= $this->quote ? 'echo ' . var_export($this->quote, true) . ';' : ''; + $escaper = $context->beginEscape(); + $escaper->enterHtmlAttribute(NodeHelpers::toText($this->name)); + if ($this->value instanceof FragmentNode && $escaper->export() === 'html/attr/url') { + foreach ($this->value->children as $child) { + $res .= $child->print($context); + $escaper->enterHtmlAttribute(null); + } + } else { + $res .= $this->value->print($context); + } + $res .= $this->quote ? 'echo ' . var_export($this->quote, true) . ';' : ''; $context->restoreEscape(); } return $res; diff --git a/src/Latte/Compiler/Nodes/Html/QuotedValue.php b/src/Latte/Compiler/Nodes/Html/QuotedValue.php deleted file mode 100644 index 3a858061c..000000000 --- a/src/Latte/Compiler/Nodes/Html/QuotedValue.php +++ /dev/null @@ -1,53 +0,0 @@ -quote, true) . ';'; - $escaper = $context->beginEscape(); - $escaper->enterHtmlAttributeQuote($this->quote); - - if ($this->value instanceof FragmentNode && $escaper->export() === 'html/attr/url') { - foreach ($this->value->children as $child) { - $res .= $child->print($context); - $escaper->enterHtmlAttribute(null, $this->quote); - } - } else { - $res .= $this->value->print($context); - } - - $res .= 'echo ' . var_export($this->quote, true) . ';'; - $context->restoreEscape(); - return $res; - } - - - public function &getIterator(): \Generator - { - yield $this->value; - } -} diff --git a/src/Latte/Compiler/TemplateParserHtml.php b/src/Latte/Compiler/TemplateParserHtml.php index 13f06e9e5..7b4f65398 100644 --- a/src/Latte/Compiler/TemplateParserHtml.php +++ b/src/Latte/Compiler/TemplateParserHtml.php @@ -261,10 +261,11 @@ private function parseAttribute(): ?Node throw $e; } - $value = $this->parseAttributeValue(); + [$value, $quote] = $this->parseAttributeValue(); return new Html\AttributeNode( name: $name, value: $value, + quote: $quote, position: $name->position, ); } @@ -282,7 +283,7 @@ private function parseAttributeName(): ?AreaNode } - private function parseAttributeValue(): ?AreaNode + private function parseAttributeValue(): ?array { $stream = $this->parser->getStream(); $save = $stream->getIndex(); @@ -293,25 +294,26 @@ private function parseAttributeValue(): ?AreaNode } $this->consumeIgnored(); - return match ($stream->peek()->type) { - Token::Quote => $this->parseAttributeQuote(), - Token::Html_Name => $this->parser->parseText(), - Token::Latte_TagOpen => $this->parser->parseFragment( - function (FragmentNode $fragment) use ($stream) { - if ($fragment->children) { - return null; - } - return match ($stream->peek()->type) { - Token::Quote => $this->parseAttributeQuote(), - Token::Html_Name => $this->parser->parseText(), - Token::Latte_TagOpen => $this->parser->parseLatteStatement(), - Token::Latte_CommentOpen => $this->parser->parseLatteComment(), - default => null, - }; + if ($quoteToken = $stream->tryConsume(Token::Quote)) { + $value = $this->parser->parseFragment( + fn() => match ($stream->peek()->type) { + Token::Quote => null, + default => $this->parser->inTextResolve(), }, - ), - default => $stream->throwUnexpectedException(), - }; + ); + $stream->consume(Token::Quote); + return [$value, $quoteToken->text]; + } + + $value = $this->parser->parseFragment( + fn() => match ($stream->peek()->type) { + Token::Html_Name => $this->parser->parseText(), + Token::Latte_TagOpen => $this->parser->parseLatteStatement(), + Token::Latte_CommentOpen => $this->parser->parseLatteComment(), + default => null, + }, + )->simplify() ?? $stream->throwUnexpectedException(); + return [$value, $value instanceof Nodes\TextNode ? null : '"']; } @@ -360,24 +362,6 @@ private function parseNAttribute(): Nodes\TextNode } - private function parseAttributeQuote(): Html\QuotedValue - { - $stream = $this->parser->getStream(); - $quoteToken = $stream->consume(Token::Quote); - $value = $this->parser->parseFragment(fn() => match ($stream->peek()->type) { - Token::Quote => null, - default => $this->parser->inTextResolve(), - }); - $node = new Html\QuotedValue( - value: $value, - quote: $quoteToken->text, - position: $quoteToken->position, - ); - $stream->consume(Token::Quote); - return $node; - } - - private function parseComment(): Html\CommentNode { $this->parser->lastIndentation = null; diff --git a/src/Latte/Runtime/Filters.php b/src/Latte/Runtime/Filters.php index bb62a02e7..850feeb6c 100644 --- a/src/Latte/Runtime/Filters.php +++ b/src/Latte/Runtime/Filters.php @@ -210,24 +210,6 @@ public static function convertHtmlToHtmlAttr(string $s): string } - /** - * Converts HTML text to unquoted attribute. The quotation marks need to be escaped. - */ - public static function convertHtmlToUnquotedAttr(string $s): string - { - return '"' . self::escapeHtmlAttr($s, false) . '"'; - } - - - /** - * Converts HTML quoted attribute to unquoted. - */ - public static function convertHtmlAttrToUnquotedAttr(string $s): string - { - return '"' . $s . '"'; - } - - /** * Converts HTML to plain text. */ diff --git a/tests/common/Compiler.errors.phpt b/tests/common/Compiler.errors.phpt index e00c6d996..ce922d866 100644 --- a/tests/common/Compiler.errors.phpt +++ b/tests/common/Compiler.errors.phpt @@ -51,9 +51,9 @@ Assert::exception( ); Assert::exception( - fn() => $latte->compile(''), + fn() => $latte->compile(''), Latte\CompileException::class, - 'Unexpected tag {=$b} (on line 1 at column 26)', + 'Unexpected \'"a"{/if\', expecting {/if} (on line 1 at column 22)', ); Assert::exception( diff --git a/tests/common/Compiler.unquoted.phpt b/tests/common/Compiler.unquoted.phpt index cf89b15ed..f90213b6c 100644 --- a/tests/common/Compiler.unquoted.phpt +++ b/tests/common/Compiler.unquoted.phpt @@ -19,15 +19,15 @@ $template = <<<'EOD' - + - {* not supported *} + - {* not supported *} + {* not supported *} diff --git a/tests/common/NodeHelpers.toText().phpt b/tests/common/NodeHelpers.toText().phpt index a17451510..304d95550 100644 --- a/tests/common/NodeHelpers.toText().phpt +++ b/tests/common/NodeHelpers.toText().phpt @@ -5,7 +5,6 @@ declare(strict_types=1); use Latte\Compiler\NodeHelpers; use Latte\Compiler\Nodes\AuxiliaryNode; use Latte\Compiler\Nodes\FragmentNode; -use Latte\Compiler\Nodes\Html\QuotedValue; use Latte\Compiler\Nodes\NopNode; use Latte\Compiler\Nodes\TextNode; use Tester\Assert; @@ -28,8 +27,5 @@ Assert::same('helloworld!', NodeHelpers::toText($fragment)); $fragment->children[] = new NopNode; // is ignored by append Assert::same('helloworld!', NodeHelpers::toText($fragment)); -$fragment->append(new QuotedValue(new TextNode('quote'), '"')); -Assert::same('helloworld!quote', NodeHelpers::toText($fragment)); - $fragment->append(new AuxiliaryNode(fn() => '')); Assert::null(NodeHelpers::toText($fragment)); diff --git a/tests/common/TemplateParser.nodes.phpt b/tests/common/TemplateParser.nodes.phpt index 76c3a4c13..b67683100 100644 --- a/tests/common/TemplateParser.nodes.phpt +++ b/tests/common/TemplateParser.nodes.phpt @@ -127,6 +127,7 @@ Assert::match(<<<'XX' | | | | | | | content: 'attr1' | | | | | | | position: 1:5 (offset 4) | | | | | | value: null + | | | | | | quote: null | | | | | | position: 1:5 (offset 4) | | | | | 2 => Latte\Compiler\Nodes\TextNode | | | | | | content: ' \n' @@ -138,6 +139,7 @@ Assert::match(<<<'XX' | | | | | | value: Latte\Compiler\Nodes\TextNode | | | | | | | content: 'val' | | | | | | | position: 2:7 (offset 17) + | | | | | | quote: null | | | | | | position: 2:1 (offset 11) | | | | | 4 => Latte\Compiler\Nodes\TextNode | | | | | | content: string @@ -148,15 +150,13 @@ Assert::match(<<<'XX' | | | | | | name: Latte\Compiler\Nodes\TextNode | | | | | | | content: 'attr3' | | | | | | | position: 3:2 (offset 22) - | | | | | | value: Latte\Compiler\Nodes\Html\QuotedValue - | | | | | | | value: Latte\Compiler\Nodes\FragmentNode - | | | | | | | | children: array (1) - | | | | | | | | | 0 => Latte\Compiler\Nodes\TextNode - | | | | | | | | | | content: 'val' - | | | | | | | | | | position: 4:2 (offset 30) - | | | | | | | | position: 4:2 (offset 30) - | | | | | | | quote: ''' - | | | | | | | position: 4:1 (offset 29) + | | | | | | value: Latte\Compiler\Nodes\FragmentNode + | | | | | | | children: array (1) + | | | | | | | | 0 => Latte\Compiler\Nodes\TextNode + | | | | | | | | | content: 'val' + | | | | | | | | | position: 4:2 (offset 30) + | | | | | | | position: 4:2 (offset 30) + | | | | | | quote: ''' | | | | | | position: 3:2 (offset 22) | | | | position: 1:4 (offset 3) | | | selfClosing: false @@ -188,7 +188,7 @@ Assert::match(<<<'XX' | | 0 => Latte\Compiler\Nodes\Html\ElementNode | | | customName: null | | | attributes: Latte\Compiler\Nodes\FragmentNode - | | | | children: array (7) + | | | | children: array (6) | | | | | 0 => Latte\Compiler\Nodes\TextNode | | | | | | content: ' ' | | | | | | position: 1:4 (offset 3) @@ -200,11 +200,9 @@ Assert::match(<<<'XX' | | | | | 3 => Latte\Compiler\Nodes\Html\AttributeNode | | | | | | name: FooNode | | | | | | | position: 1:28 (offset 27) - | | | | | | value: Latte\Compiler\Nodes\FragmentNode - | | | | | | | children: array (1) - | | | | | | | | 0 => FooNode - | | | | | | | | | position: 1:45 (offset 44) + | | | | | | value: FooNode | | | | | | | position: 1:45 (offset 44) + | | | | | | quote: '"' | | | | | | position: 1:28 (offset 27) | | | | | 4 => Latte\Compiler\Nodes\TextNode | | | | | | content: ' ' @@ -221,21 +219,19 @@ Assert::match(<<<'XX' | | | | | | | | | content: 'b' | | | | | | | | | position: 1:69 (offset 68) | | | | | | | position: 1:58 (offset 57) - | | | | | | value: Latte\Compiler\Nodes\TextNode - | | | | | | | content: 'c' - | | | | | | | position: 1:71 (offset 70) - | | | | | | position: 1:58 (offset 57) - | | | | | 6 => Latte\Compiler\Nodes\Html\AttributeNode - | | | | | | name: Latte\Compiler\Nodes\FragmentNode - | | | | | | | children: array (2) - | | | | | | | | 0 => FooNode + | | | | | | value: Latte\Compiler\Nodes\FragmentNode + | | | | | | | children: array (3) + | | | | | | | | 0 => Latte\Compiler\Nodes\TextNode + | | | | | | | | | content: 'c' + | | | | | | | | | position: 1:71 (offset 70) + | | | | | | | | 1 => FooNode | | | | | | | | | position: 1:72 (offset 71) - | | | | | | | | 1 => Latte\Compiler\Nodes\TextNode + | | | | | | | | 2 => Latte\Compiler\Nodes\TextNode | | | | | | | | | content: 'd' | | | | | | | | | position: 1:78 (offset 77) - | | | | | | | position: 1:72 (offset 71) - | | | | | | value: null - | | | | | | position: 1:72 (offset 71) + | | | | | | | position: 1:71 (offset 70) + | | | | | | quote: '"' + | | | | | | position: 1:58 (offset 57) | | | | position: 1:4 (offset 3) | | | selfClosing: false | | | content: null diff --git a/tests/common/contentType.compatibility.phpt b/tests/common/contentType.compatibility.phpt index edf987c6a..63b703e9a 100644 --- a/tests/common/contentType.compatibility.phpt +++ b/tests/common/contentType.compatibility.phpt @@ -26,7 +26,7 @@ test('', function () { ); Assert::same( - 'b"ar', + 'b"ar', $latte->renderToString('{block foo}{$value}{/block}', ['value' => 'b"ar']), ); @@ -141,24 +141,16 @@ test('', function () { Assert::match('', $latte->renderToString('context4', ['foo' => 'b"ar'])); - Assert::exception( - fn() => $latte->renderToString('context5', ['foo' => 'b"ar']), - Latte\RuntimeException::class, - 'Overridden block foo with content type HTML/UNQUOTED-ATTR by incompatible type HTML/ATTR.', - ); + Assert::match('', $latte->renderToString('context5', ['foo' => 'b"ar'])); - Assert::exception( - fn() => $latte->renderToString('context6', ['foo' => 'b"ar']), - Latte\RuntimeException::class, - 'Overridden block foo with content type HTML/UNQUOTED-ATTR by incompatible type HTML/ATTR.', - ); + Assert::match('', $latte->renderToString('context6', ['foo' => 'b"ar'])); Assert::match('', $latte->renderToString('context7', ['foo' => 'b"ar'])); Assert::exception( fn() => $latte->renderToString('context8', ['foo' => 'b"ar']), Latte\RuntimeException::class, - 'Overridden block foo with content type HTML/UNQUOTED-ATTR by incompatible type HTML/COMMENT.', + 'Overridden block foo with content type HTML/ATTR by incompatible type HTML/COMMENT.', ); }); @@ -257,11 +249,7 @@ Assert::same('

</script>

', $latte->renderToString('context1')); Assert::same('

', $latte->renderToString('context2')); -Assert::exception( - fn() => $latte->renderToString('context3'), - Latte\RuntimeException::class, - "Including 'js.latte' with content type JS into incompatible type HTML/UNQUOTED-ATTR.", -); +Assert::same('

', $latte->renderToString('context3')); Assert::same('', $latte->renderToString('context4')); diff --git a/tests/common/expected/Compiler.unquoted.attrs.html b/tests/common/expected/Compiler.unquoted.attrs.html index e74b2e202..a98986069 100644 --- a/tests/common/expected/Compiler.unquoted.attrs.html +++ b/tests/common/expected/Compiler.unquoted.attrs.html @@ -6,10 +6,10 @@ - + - + - \ No newline at end of file + \ No newline at end of file diff --git a/tests/common/expected/Compiler.unquoted.attrs.php b/tests/common/expected/Compiler.unquoted.attrs.php index 3ade4dbcf..7ff72a461 100644 --- a/tests/common/expected/Compiler.unquoted.attrs.php +++ b/tests/common/expected/Compiler.unquoted.attrs.php @@ -6,51 +6,51 @@ final class Template%a% extends Latte\Runtime\Template public function main(array $ʟ_args): void { %A% - echo ' - - + + - + echo '"> + echo '="'; + echo LR\Filters::escapeHtmlAttr($x) /* line %d% */; + echo '"> - + - - + + echo 'b="c'; + echo LR\Filters::escapeHtmlAttr($x) /* line %d% */; + echo 'd"> '; } } diff --git a/tests/common/expected/contentType.xml.php b/tests/common/expected/contentType.xml.php index 656b9de6b..1a7ef856f 100644 --- a/tests/common/expected/contentType.xml.php +++ b/tests/common/expected/contentType.xml.php @@ -111,23 +111,23 @@ public function main(array $ʟ_args): void echo ' -

+ echo '">

-

+

-

+

'; } diff --git a/tests/common/templates/contentType.xml.latte b/tests/common/templates/contentType.xml.latte index 56b62971b..317d85692 100644 --- a/tests/common/templates/contentType.xml.latte +++ b/tests/common/templates/contentType.xml.latte @@ -56,7 +56,7 @@ var html = {$el}; -

+

diff --git a/tests/filters/convertHtmlAttrToUnquotedAttr.phpt b/tests/filters/convertHtmlAttrToUnquotedAttr.phpt deleted file mode 100644 index 3da34fd95..000000000 --- a/tests/filters/convertHtmlAttrToUnquotedAttr.phpt +++ /dev/null @@ -1,19 +0,0 @@ -"', Filters::convertHtmlAttrToUnquotedAttr('< & \' >')); - -Assert::same('"""', Filters::convertHtmlAttrToUnquotedAttr('"')); // should not occur diff --git a/tests/filters/convertHtmlToUnquotedAttr.phpt b/tests/filters/convertHtmlToUnquotedAttr.phpt deleted file mode 100644 index 7057b3109..000000000 --- a/tests/filters/convertHtmlToUnquotedAttr.phpt +++ /dev/null @@ -1,25 +0,0 @@ -')); -Assert::same('"""', Filters::convertHtmlToUnquotedAttr('"')); - -// invalid UTF-8 -Assert::same("\"foo \u{FFFD} bar\"", Filters::convertHtmlToUnquotedAttr("foo \u{D800} bar")); // invalid codepoint high surrogates -Assert::same("\"foo \u{FFFD}" bar\"", Filters::convertHtmlToUnquotedAttr("foo \xE3\x80\x22 bar")); // stripped UTF - -// JS -Assert::same('"hello { worlds }"', Filters::convertHtmlToUnquotedAttr('hello { worlds }')); diff --git a/tests/tags/expected/print.xss.php b/tests/tags/expected/print.xss.php index fc30f3a87..35d047d9a 100644 --- a/tests/tags/expected/print.xss.php +++ b/tests/tags/expected/print.xss.php @@ -4,14 +4,14 @@ echo LR\Filters::escapeHtmlText($el2) /* line %d% */; echo ' -

-

+

+