diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php index 9a8ade8d5..c011d3860 100644 --- a/tests/unit/framework/parsers/TemplateParserV2Test.php +++ b/tests/unit/framework/parsers/TemplateParserV2Test.php @@ -173,6 +173,16 @@ class TemplateParserV2Test extends \Codeception\Test\Unit $target = ""; $this->assertEquals($target, $this->_parse($source)); + // External script + $source = ''; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + + // External webfont + $source = ''; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + // Path relative to Rhymix installation directory $source = ''; $target = ""; @@ -214,6 +224,16 @@ class TemplateParserV2Test extends \Codeception\Test\Unit $target = ""; $this->assertEquals($target, $this->_parse($source)); + // Blade-style external script + $source = "@load ('//cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.min.js')"; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + + // Blade-style external webfont + $source = "@load('https://fonts.googleapis.com/css2?family=Roboto&display=swap')"; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + // Blade-style path relative to Rhymix installation directory $source = '@load ("^/common/js/foobar.js")'; $target = ""; @@ -289,52 +309,669 @@ class TemplateParserV2Test extends \Codeception\Test\Unit public function testOutputFilters() { + // Filters with no whitespace + $source = '{$foo|upper|noescape}'; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + // Randomly distributed whitespace + $source = '{$foo | upper |noescape }'; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Pipe character in filter option + $source = "{\$foo|join:'|'|noescape}"; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Pipe character in filter option, escaped + $source = "{\$foo|join:'foo\|bar'|noescape}"; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Pipe character in OR operator + $source = '{$foo || $bar | noescape}'; + $target = "foo || \$__Context->bar; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Autoescape + $source = '{{ $foo|autoescape }}'; + $target = "foo ?? '', \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Autolang + $source = '{{ $foo|autolang }}'; + $target = "\w+$/', \$__Context->foo ?? '') ? (\$__Context->foo ?? '') : htmlspecialchars(\$__Context->foo ?? '', \ENT_QUOTES, 'UTF-8', false)); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Escape + $source = '{{ $foo|escape }}'; + $target = "foo ?? '', \ENT_QUOTES, 'UTF-8', true); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Noescape + $source = '{{ $foo|escape|noescape }}'; + $target = "foo ?? ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Escape for Javascript + $source = '{{ $foo|js }}'; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Escape for Javascript (alternate name) + $source = '{{ $foo|escapejs }}'; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // JSON using context-aware escape + $source = '{{ $foo|json }}'; + $target = implode('', [ + "config->context === 'JS' ? ", + "(json_encode(\$__Context->foo ?? '', \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES)) : ", + "htmlspecialchars(json_encode(\$__Context->foo ?? '', \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES), \ENT_QUOTES, 'UTF-8', false); ?>", + ]); + $this->assertEquals($target, $this->_parse($source)); + + // strip_tags + $source = '{{ $foo|strip }}'; + $target = "foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // strip_tags (alternate name) + $source = '{{ $foo|upper|strip_tags }}'; + $target = "foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Trim + $source = '{{ $foo|trim|noescape }}'; + $target = "foo ?? ''); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // URL encode + $source = '{{ $foo|trim|urlencode }}'; + $target = "foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Lowercase + $source = '{{ $foo|trim|lower }}'; + $target = "foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Uppercase + $source = '{{ $foo|upper|escape }}'; + $target = "foo ?? ''), \ENT_QUOTES, 'UTF-8', true); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // nl2br() + $source = '{{ $foo|nl2br }}'; + $target = "foo ?? '', \ENT_QUOTES, 'UTF-8', false)); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // nl2br() with gratuitous escape + $source = '{{ $foo|nl2br|escape }}'; + $target = "foo ?? '', \ENT_QUOTES, 'UTF-8', false)), \ENT_QUOTES, 'UTF-8', true); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Array join (default joiner is comma) + $source = '{{ $foo|join }}'; + $target = "foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Array join (custom joiner) + $source = '{{ $foo|join:"!@!" }}'; + $target = "foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Date conversion (default format) + $source = '{{ $item->regdate | date }}'; + $target = "item->regdate ?? ''), 'Y-m-d H:i:s'), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Date conversion (custom format) + $source = "{{ \$item->regdate | date:'n/j H:i' }}"; + $target = "item->regdate ?? ''), 'n/j H:i'), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Date conversion (custom format in variable) + $source = "{{ \$item->regdate | date:\$format }}"; + $target = "item->regdate ?? ''), \$__Context->format), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number format + $source = '{{ $num | format }}'; + $target = "num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number format (alternate name) + $source = '{{ $num | number_format }}'; + $target = "num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number format (custom format) + $source = '{{ $num | number_format:6 }}'; + $target = "num ?? '', '6'), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number format (custom format in variable) + $source = '{{ $num | number_format:$digits }}'; + $target = "num ?? '', \$__Context->digits), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number shorten + $source = '{{ $num | shorten }}'; + $target = "num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number shorten (alternate name) + $source = '{{ $num | number_shorten }}'; + $target = "num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Number shorten (custom format) + $source = '{{ $num | number_shorten:1 }}'; + $target = "num ?? '', '1'), \ENT_QUOTES, 'UTF-8', false); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Link + $source = '{{ $foo|link }}'; + $target = "foo ?? '', \ENT_QUOTES, 'UTF-8', false)) . '\">' . (htmlspecialchars(\$__Context->foo ?? '', \ENT_QUOTES, 'UTF-8', false)) . ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Link (custom link text) + $source = '{{ $foo|link:"Hello World" }}'; + $target = "' . (htmlspecialchars(\$__Context->foo ?? '', \ENT_QUOTES, 'UTF-8', false)) . ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Link (custom link text in variable) + $source = '{{ $foo|link:$bar->baz[0] }}'; + $target = "bar->baz[0], \ENT_QUOTES, 'UTF-8', false)) . '\">' . (htmlspecialchars(\$__Context->foo ?? '', \ENT_QUOTES, 'UTF-8', false)) . ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + } + + public function testVariableScopeConversion() + { + // Local variable + $source = '{$foo|noescape}'; + $target = "foo ?? ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Class and array keys + $source = '{!! ClassName::getInstance()->$foo[$bar] !!}'; + $target = "{\$__Context->foo}[\$__Context->bar]; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Superglobals + $source = "{!! \$_SERVER['HTTP_USER_AGENT'] . \$GLOBALS[\$_GET['foo']] !!}"; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + + // $this + $source = "{!! \$this->func(\$args) !!}"; + $target = "func(\$__Context->args); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // $lang + $source = "{!! \$lang->cmd_yes !!}"; + $target = "lang->cmd_yes ?? ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // $loop + $source = "{!! \$loop->first !!}"; + $target = "first ?? ''; ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Escaped dollar sign + $source = "{!! \\\$escaped !!}"; + $target = ""; + $this->assertEquals($target, $this->_parse($source)); + + // Escaped and unescaped variables used together in closure + $source = "{!! (function(\\\$i) use(\$__Context) { return \\\$i * \$j; })(\$k); !!}"; + $target = "j; })(\$__Context->k);; ?>"; + $this->assertEquals($target, $this->_parse($source)); } public function testPathConversion() { + // Image + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + // + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // with poster attribute and inside + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // with path relative to the Rhymix installation directory + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // with src + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // Script tag with local path + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // Absolute URL + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // External URL + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // data: URL + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // file: URL + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // srcset + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); } public function testBlockConditions() { + // @if in comments + $source = 'Hello World'; + $target = 'cond): ?>Hello World'; + $this->assertEquals($target, $this->_parse($source)); + // @if in its own line, with @elseif and @else + $source = implode("\n", [ + '@if($foo)', + 'Hello World', + '@elseif($bar)', + 'Goodbye World', + '@else', + 'So long and thx 4 all the fish', + '@endif', + ]); + $target = implode("\n", [ + 'foo): ?>', + 'Hello World', + 'bar): ?>', + 'Goodbye World', + '', + 'So long and thx 4 all the fish', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // nested @if and @unless with inconsistent spacing before parenthesis + $source = implode("\n", [ + '@if($cond)', + '@unless ($cond)', + 'Hello World', + '@endunless', + '@endif', + ]); + $target = implode("\n", [ + 'cond): ?>', + 'cond)): ?>', + 'Hello World', + '', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // nested @if, @unless, and @for with legacy @end + $source = implode("\n", [ + '@if ($cond)', + '@for ($i = 0; $i < 10; $i++)', + '', + 'Hello World', + '@end', + '@end', + '', + ]); + $target = implode("\n", [ + 'cond): ?>', + 'i = 0; $__Context->i < 10; $__Context->i++): ?>', + 'cond)): ?>', + 'Hello World', + '', + '', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // @while with legacy @end + $source = 'Hello World'; + $target = 'isBar("baz")): ?>Hello World'; + $this->assertEquals($target, $this->_parse($source)); + + // @switch with @case, @default, @continue, and @break + $source = implode("\n", [ + '@switch ($str)', + '@case (1)', + '', + '@continue', + '@case(3)', + '@break', + '@default', + '@if (42)', + 'Hello World', + '@end', + '@end', + ]); + $target = implode("\n", [ + 'str): ?>', + '', + '', + '', + '', + '', + '', + '', + 'Hello World', + '', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // @foreach + $source = implode("\n", [ + '', + 'Hello World', + '', + ]); + $target = implode("\n", [ + 'list ?? []; foreach ($__tmp as $__Context->key => $__Context->val): ?>', + 'Hello World', + '', + ]); + $parsed = $this->_parse($source); + $tmpvar = preg_match('/(\$__tmp_[0-9a-f]{14})/', $parsed, $m) ? $m[1] : ''; + $target = strtr($target, ['$__tmp' => $tmpvar]); + $this->assertEquals($target, $parsed); + + // @forelse with @empty + $source = implode("\n", [ + '@forelse ($list as $key => $val)', + 'Hello World', + '@empty', + 'Nothing Here!', + '@end', + ]); + $target = implode("\n", [ + 'list ?? []; if($__tmp): foreach ($__tmp as $__Context->key => $__Context->val): ?>', + 'Hello World', + '', + 'Nothing Here!', + '', + ]); + $parsed = $this->_parse($source); + $tmpvar = preg_match('/(\$__tmp_[0-9a-f]{14})/', $parsed, $m) ? $m[1] : ''; + $target = strtr($target, ['$__tmp' => $tmpvar]); + $this->assertEquals($target, $parsed); + + // @once + $source = implode("\n", [ + '@once', + 'Hello World', + '@endonce', + ]); + $target = implode("\n", [ + '', + 'Hello World', + '', + ]); + $parsed = $this->_parse($source); + $tmpvar = preg_match('/\'([0-9a-f]{14})\'/', $parsed, $m) ? $m[1] : ''; + $target = strtr($target, ['$UNIQ' => $tmpvar]); + $this->assertEquals($target, $parsed); + + // @isset and @unset + $source = ''; + $target = 'foo)): ?>bar)): ?>'; + $this->assertEquals($target, $this->_parse($source)); + + // @empty + $source = ''; + $target = 'foo)): ?>'; + $this->assertEquals($target, $this->_parse($source)); + + // @admin + $source = implode("\n", [ + '@admin', + 'Welcome!', + '@endadmin', + ]); + $target = implode("\n", [ + 'user->isAdmin()): ?>', + 'Welcome!', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // @auth, @member and @guest + $source = implode("\n", [ + '@auth', + '@member', + 'Welcome back!', + '@endmember', + '@end', + '@guest', + 'Please join!', + '@endguest', + ]); + $target = implode("\n", [ + 'user->isMember()): ?>', + 'user->isMember()): ?>', + 'Welcome back!', + '', + '', + 'user->isMember()): ?>', + 'Please join!', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // @desktop and @mobile + $source = implode("\n", [ + '@desktop', + '4K or GTFO!', + '@end', + '@mobile', + 'USB C is the way to go~', + '@endmobile', + ]); + $target = implode("\n", [ + 'm): ?>', + '4K or GTFO!', + '', + 'm): ?>', + 'USB C is the way to go~', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); } public function testInlineConditions() { + // XE-style pipe with 'if' attribute + $source = 'isSecret()" />'; + $target = 'oDocument->isSecret()): ?> readonly="readonly" />'; + $this->assertEquals($target, $this->_parse($source)); + // With boolean (valueless) attribute + $source = 'isSecret()" />'; + $target = 'oDocument->isSecret()): ?> disabled="disabled" />'; + $this->assertEquals($target, $this->_parse($source)); + + // Support 'cond' attribute for backward compatibility + $source = 'ONE'; + $target = 'foo): ?> selected="selected">ONE'; + $this->assertEquals($target, $this->_parse($source)); + + // Support 'when' and 'unless' attributes + $source = 'ONE'; + $target = 'foo): ?> selected="selected"bar)): ?> disabled="disabled">ONE'; + $this->assertEquals($target, $this->_parse($source)); + + // Blade-style @checked + $source = 'isAccessible() && in_array($this->user->member_srl, [1, 2, 3])) />'; + $target = 'oDocument->isAccessible() && in_array($this->user->member_srl, [1, 2, 3])): ?> checked="checked" />'; + $this->assertEquals($target, $this->_parse($source)); + + // Blade-style @selected + $source = 'TWO'; + $target = 'foo): ?> selected="selected">TWO'; + $this->assertEquals($target, $this->_parse($source)); + + // Blade-style @disabled + $source = ''; + $target = 'foo) === $__Context->bar("baz")): ?> disabled="disabled">'; + $this->assertEquals($target, $this->_parse($source)); + + // Blade-style @readonly and @required + $source = 'require_title) />'; + $target = ' readonly="readonly"member_info->require_title): ?> required="required" />'; + $this->assertEquals($target, $this->_parse($source)); } public function testMiscDirectives() { + // Insert CSRF token + $source = '@csrf'; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + // JSON with variable + $source = '@json($var)'; + $target = implode('', [ + 'config->context === \'JS\' ? ', + 'json_encode($__Context->var, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES) : ', + 'htmlspecialchars(json_encode($__Context->var, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES), \ENT_QUOTES, \'UTF-8\', false); ?>', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // JSON with literal array + $source = '@json(["foo" => 1, "bar" => 2])'; + $target = implode('', [ + 'config->context === \'JS\' ? ', + 'json_encode(["foo" => 1, "bar" => 2], \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES) : ', + 'htmlspecialchars(json_encode(["foo" => 1, "bar" => 2], \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES), \ENT_QUOTES, \'UTF-8\', false); ?>', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // Lang code with variable + $source = '@lang($var->name)'; + $target = 'config->context === \'JS\' ? escape_js(lang($__Context->var->name)) : lang($__Context->var->name); ?>'; + $this->assertEquals($target, $this->_parse($source)); + + // Lang code with literal name + $source = "@lang('board.cmd_list_items')"; + $target = "config->context === 'JS' ? escape_js(lang('board.cmd_list_items')) : lang('board.cmd_list_items'); ?>"; + $this->assertEquals($target, $this->_parse($source)); + + // Lang code with class alias + $source = "@use('Rhymix\Framework\Lang', 'Lang')\n" . '@lang(Lang::getLang())'; + $target = "\n" . 'config->context === \'JS\' ? escape_js(lang(Rhymix\Framework\Lang::getLang())) : lang(Rhymix\Framework\Lang::getLang()); ?>'; + $this->assertEquals($target, $this->_parse($source)); } public function testComments() { + // XE-style comment + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + // Blade-style comment + $source = '{{-- This is a comment --}}'; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // Not deleted + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); + + // Not deleted + $source = ''; + $target = ''; + $this->assertEquals($target, $this->_parse($source)); } public function testVerbatim() { + // Don't convert this expression, but remove the @ + $source = '@{{ $foobar }}'; + $target = '{{ $foobar }}'; + $this->assertEquals($target, $this->_parse($source)); + // Don't convert this expression, but remove the extra @ + $source = implode("\n", [ + '@@if(true)', + '@@endif', + ]); + $target = implode("\n", [ + '@if(true)', + '@endif', + ]); + $this->assertEquals($target, $this->_parse($source)); + + // @verbatim block + $source = implode("\n", [ + '@verbatim', + '@if (true)', + '{{ $foobar }}', + '@endif', + '@endverbatim', + ]); + $target = implode("\n", [ + '', + '@if (true)', + '{{ $foobar }}', + '@endif', + '', + ]); + $this->assertEquals($target, $this->_parse($source)); } public function testRawPhpCode() { + // Regular PHP tags + $source = ''; + $target = 'foo = 42; ?>'; + $this->assertEquals($target, $this->_parse($source)); - } - - public function testAutoEscape() - { - - } - - public function testCurlyBracesAndVars() - { + // XE-style {@ ... } notation + $source = '{@ $foo = 42; }'; + $target = 'foo = 42; ?>'; + $this->assertEquals($target, $this->_parse($source)); + // Blade-style @php and @endphp directives + $source = '@php $foo = 42; @endphp'; + $target = 'foo = 42; ?>'; + $this->assertEquals($target, $this->_parse($source)); } public function testCompile()
Hello World
Goodbye World
So long and thx 4 all the fish
Nothing Here!
Welcome!
Welcome back!
Please join!
4K or GTFO!
USB C is the way to go~
@lang(Lang::getLang())
config->context === \'JS\' ? escape_js(lang(Rhymix\Framework\Lang::getLang())) : lang(Rhymix\Framework\Lang::getLang()); ?>
{{ $foobar }}