From f0554137ec81d1367285ada222d2c3205c7c5ced Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Sat, 14 Oct 2023 23:36:33 +0900 Subject: [PATCH] Initial implementation of Template Parser v2 --- common/framework/Template.php | 62 +- .../parsers/template/TemplateParser_v2.php | 997 +++++++++++++++++- 2 files changed, 1040 insertions(+), 19 deletions(-) diff --git a/common/framework/Template.php b/common/framework/Template.php index 8e5e4b965..5d6900a4c 100644 --- a/common/framework/Template.php +++ b/common/framework/Template.php @@ -25,6 +25,7 @@ class Template public $cache_path; public $cache_enabled = true; public $ob_level = 0; + public $vars; /** * Static properties @@ -56,6 +57,7 @@ class Template $this->config = new \stdClass; $this->config->version = 1; $this->config->autoescape = false; + $this->config->context = 'HTML'; // Set user information. $this->user = Session::getMemberInfo() ?: new Helpers\SessionHelper(); @@ -131,6 +133,48 @@ class Template $this->cache_enabled = false; } + /** + * Check if the template file exists. + * + * @return bool + */ + public function exists(): bool + { + return $this->exists ? true : false; + } + + /** + * Get vars. + * + * @return ?object + */ + public function getVars(): ?object + { + return $this->vars; + } + + /** + * Set vars. + * + * @param array|object $vars + * @return void + */ + public function setVars($vars): void + { + if (is_array($vars)) + { + $this->vars = (object)$vars; + } + elseif (is_object($vars)) + { + $this->vars = $vars; + } + else + { + throw new Exception('Template vars must be an array or object'); + } + } + /** * Compile and execute a template file. * @@ -254,22 +298,17 @@ class Template $content = preg_replace(['/^\xEF\xBB\xBF/', '/\r\n/'], ['', "\n"], $content); // Check the config tag: or - $content = preg_replace_callback('!^!', function($match) { + $content = preg_replace_callback('!(?<=^|\n)!', function($match) { $this->config->{$match[1]} = ($match[1] === 'version' ? intval($match[2]) : toBool($match[2])); return sprintf('config->%s = %s; ?>', $match[1], var_export($this->config->{$match[1]}, true)); }, $content); // Check the alternative version directive: @version(2) - if (preg_match('/(?:^|\s)@version\(([0-9]+)\)/', $content, $matches)) - { - $this->config->version = intval($matches[1]); - } - - // Turn autoescape on if the version is 2 or greater. - if ($this->config->version >= 2) - { + $content = preg_replace_callback('!(?<=^|\n)@version\s?\(([0-9]+)\)!', function($match) { + $this->config->version = intval($match[1]); $this->config->autoescape = true; - } + return sprintf('config->version = %s; $this->config->autoescape = true; ?>', var_export($this->config->version, true)); + }, $content); // Call a version-specific parser to convert template code into PHP. $class_name = '\Rhymix\Framework\Parsers\Template\TemplateParser_v' . $this->config->version; @@ -287,8 +326,7 @@ class Template protected function _execute(): string { // Import Context and lang as local variables. - $__Context = \Context::getAll(); - global $lang; + $__Context = $this->vars ?: \Context::getAll(); // Start the output buffer. $this->ob_level = ob_get_level(); diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php index a236a1c8b..108075f27 100644 --- a/common/framework/parsers/template/TemplateParser_v2.php +++ b/common/framework/parsers/template/TemplateParser_v2.php @@ -10,6 +10,51 @@ class TemplateParser_v2 * Store template info here. */ public $template; + public $source_type; + + /** + * Temporary information for conversion. + */ + protected $_aliases = []; + protected $_stack = []; + protected $_uniq_order = 1; + + // Loop definitions. + protected static $_loopdef = [ + 'if' => ['if (%s):', 'endif;'], + 'unless' => ['if (!(%s)):', 'endif;'], + 'for' => ['for (%s):', 'endfor;'], + 'while' => ['while (%s):', 'endwhile;'], + 'switch' => ['switch (%s):', 'endswitch;'], + 'foreach' => [ + '$__tmp_%uniq = %array ?? []; foreach ($__tmp_%uniq as %remainder):', + 'endforeach;', + ], + 'forelse' => [ + '$__tmp_%uniq = %array ?? []; if($__tmp_%uniq): foreach ($__tmp_%uniq as %remainder):', + 'endforeach; else:', + 'endif;', + ], + 'once' => [ + "if (!isset(\$GLOBALS['tplv2_once']['%uniq'])):", + "endif; \$GLOBALS['tplv2_once']['%uniq'] = true;", + ], + 'isset' => ['if (isset(%s)):', 'endif;'], + 'unset' => ['if (!isset(%s)):', 'endif;'], + 'empty' => ['if (empty(%s)):', 'endif;'], + 'admin' => ['if ($this->user->isAdmin()):', 'endif;'], + 'auth' => ['if ($this->user->isMember()):', 'endif;'], + 'member' => ['if ($this->user->isMember()):', 'endif;'], + 'guest' => ['if (!$this->user->isMember()):', 'endif;'], + 'desktop' => ['if (!$__Context->m):', 'endif;'], + 'mobile' => ['if ($__Context->m):', 'endif;'], + 'else' => ['else:'], + 'elseif' => ['elseif (%s):'], + 'case' => ['case %s:'], + 'default' => ['default:'], + 'continue' => ['continue;'], + 'break' => ['break;'], + ]; /** * Convert template code into PHP. @@ -22,16 +67,954 @@ class TemplateParser_v2 { // Store template info in instance property. $this->template = $template; + $this->source_type = preg_match('!^((?:m\.)?[a-z]+)/!', $template->relative_dirname, $match) ? $match[1] : null; - // Convert echo statements. - $content = preg_replace('!\{([^{}]+)\}!', '', $content); + // Preprocessing. + $content = $this->_preprocess($content); - // Remove spaces before and after all PHP tags, in order to maintain clean alignment. - $content = preg_replace([ - '!(?<=^|\n)([\x20\x09]+)(<\?(?:php\b|=))!', - '!(\?>)([\x20\x09]+)(?=$|\r|\n)!', - ], ['$2', '$1'], $content); + // Apply conversions. + $content = $this->_addContextSwitches($content); + $content = $this->_removeComments($content); + $content = $this->_convertRelativePaths($content); + $content = $this->_convertPHPSections($content); + $content = $this->_convertVerbatimSections($content); + $content = $this->_convertClassAliases($content); + $content = $this->_convertIncludes($content); + $content = $this->_convertAssets($content); + $content = $this->_convertLoopDirectives($content); + $content = $this->_convertInlineDirectives($content); + $content = $this->_convertMiscDirectives($content); + $content = $this->_convertEchoStatements($content); + + // Postprocessing. + $content = $this->_postprocess($content); return $content; } + + /** + * Preprocessing. + * + * @param string $content + * @return string + */ + protected function _preprocess(string $content): string + { + // Prevent direct invocation. + $content = '' . $content; + + // Remove trailing whitespace. + $content = preg_replace('#[\x20\x09]+$#m', '', $content); + + return $content; + } + + /** + * Insert context switch points (HTML <-> JS). + * + * @param string $content + * @return string + */ + protected function _addContextSwitches(string $content): string + { + return preg_replace_callback('#(]*)?|config->context = "HTML"; ?>' . $match[1]; + } + else + { + return $match[1] . 'config->context = "JS"; ?>'; + } + }, $content); + } + + /** + * Remove comments that should not be visible in the output. + * + * + * {{-- Blade-style Comment --}} + * + * @param string $content + * @return string + */ + protected function _removeComments(string $content): string + { + return preg_replace([ + '##', + '#\{\{--[^\n]+?--\}\}#', + ], '', $content); + } + + /** + * Convert relative paths to absolute paths. + * + * @param string $content + * @return string + */ + protected function _convertRelativePaths(string $content): string + { + // Get the base path for this template. + $basepath = \RX_BASEURL . $this->template->relative_dirname; + + // Convert all src and srcset attributes. + $regexp = '#(<(?:img|audio|video|script|input|source|link)\s[^>]*)(src|srcset)="([^"]+)"#'; + return preg_replace_callback($regexp, function($match) use ($basepath) { + if ($match[2] === 'src') + { + $src = trim($match[3]); + return $match[1] . sprintf('src="%s"', self::_isRelativePath($src) ? self::_convertRelativePath($src, $basepath) : $src); + } + else + { + $srcset = array_map('trim', explode(',', $match[3])); + $result = array_map(function($src) use($basepath) { + return self::_isRelativePath($src) ? self::_convertRelativePath($src, $basepath) : $src; + }, array_filter($srcset, function($src) { + return !empty($src); + })); + return $match[1] . sprintf('srcset="%s"', implode(', ', $result)); + } + }, $content); + } + + /** + * Convert PHP sections. + * + * Unlike v1, all variables in all PHP code belong to the same scope. + * + * Supported syntaxes: + * + * + * {@ ... } + * @php ... @endphp + * + * @param string $content + * @return string + */ + protected function _convertPHPSections(string $content): string + { + $callback = function($match) { + $open = ''; + return $open . self::_convertVariableScope($match[2]) . $close; + }; + + $content = preg_replace_callback('#(<\?php|<\?(?!=))(.+?)(\?>)#s', $callback, $content); + $content = preg_replace_callback('#(\{@)(.+?)(\})#s', $callback, $content); + $content = preg_replace_callback('#(@php)(.+?)(@endphp)#s', $callback, $content); + return $content; + } + + /** + * Convert verbatim sections. + * + * Nothing inside a @verbatim ... @endverbatim section will be converted. + * + * @param string $content + * @return string + */ + protected function _convertVerbatimSections(string $content): string + { + $content = preg_replace_callback('#(@verbatim)(.+?)(@endverbatim)#s', function($match) { + return preg_replace(['#(? + * + * Blade-style syntax: + * @use('Rhymix\Modules\Foobar\Models\HelloWorld', 'HelloWorldModel') + * + * @param string $content + * @return string + */ + protected function _convertClassAliases(string $content): string + { + // Find all alias directives. + $regexp = '#^[\x09\x20]*(?:|@use\x20?\([\'"]([^\'"]+)[\'"],\s*[\'"]([^\'"]+)[\'"]\))[\x09\x20]*$#m'; + $content = preg_replace_callback($regexp, function($match) { + $class = isset($match[3]) ? $match[3] : $match[1]; + $alias = isset($match[4]) ? $match[4] : $match[2]; + $this->_aliases[$alias] = $class; + return ''; + }, $content); + + // Replace aliases. + if (count($this->_aliases)) + { + $regexp = implode('|', array_map(function($str) { + return preg_quote($str, '#'); + }, array_keys($this->_aliases))); + + $content = preg_replace_callback('#\b(new\s+)?(' . $regexp . ')\b#', function($match) { + return $match[1] . $this->_aliases[$match[2]]; + }, $content); + } + + return $content; + } + + /** + * Convert include directives. + * + * Templates can be included conditionally + * using the 'if', 'when', and 'unless' attributes. + * + * Templates can be included with an array of variables that will + * only be available in the included template. + * If variables are supplied, global variables will not be available + * inside of the included template. + * + * XE-style syntax: + * + * + * + * Blade-style syntax: + * @include('view') + * @include('view.html', $vars) + * + * @param string $content + * @return string + */ + protected function _convertIncludes(string $content): string + { + // Convert XE-style include directives. + $regexp = '#^[\x09\x20]*()[\x09\x20]*$#m'; + $content = preg_replace_callback($regexp, function($match) { + $attrs = self::_getTagAttributes($match[1]); + $path = $attrs['src'] ?? ($attrs['target'] ?? null); + if (!$path) return $match[0]; + $tpl = 'relative_dirname, "' . $path . '"); '; + $tpl .= !empty($attrs['vars']) ? ' $__tpl->setVars(' . $attrs['vars'] . '); ' : ''; + $tpl .= 'echo $__tpl->compile(); ?>'; + if (!empty($attrs['if']) || !empty($attrs['when'])) + { + $tpl = '' . $tpl . ''; + } + if (!empty($attrs['unless'])) + { + $tpl = '' . $tpl . ''; + } + return self::_escapeVars($tpl); + }, $content); + + // Convert Blade-style include directives. + $regexp = '#^[\x09\x20]*@(include(?:If|When|Unless)?)\x20?\((.+?)\)[\x09\x20]*$#sm'; + $content = preg_replace_callback($regexp, function($match) { + if ($match[1] === 'include') + { + $tpl = 'relative_dirname, $__path); '; + $tpl .= 'if ($__vars) $__tpl->setVars($__vars); ' ; + $tpl .= 'echo $__tpl->compile(); })(' . $match[2] . '); ?>'; + } + elseif ($match[1] === 'includeIf') + { + $tpl = 'relative_dirname, $__path); '; + $tpl .= 'if (!$__tpl->exists()) return; '; + $tpl .= 'if ($__vars) $__tpl->setVars($__vars); ' ; + $tpl .= 'echo $__tpl->compile(); })(' . $match[2] . '); ?>'; + } + else + { + $tpl = 'relative_dirname, $__path); '; + $tpl .= 'if ($__vars) $__tpl->setVars($__vars); ' ; + $tpl .= 'echo $__tpl->compile(); })("' . $match[1] . '", ' . $match[2] . '); ?>'; + } + return self::_escapeVars($tpl); + }, $content); + + return $content; + } + + /** + * Convert asset loading directives. + * + * This can be used to load nearly every kind of asset, from scripts + * and stylesheets to lang files to Rhymix core Javascript plugins. + * + * XE-style syntax: + * + * + * + * + * Blade-style syntax: + * @load('dir/script.js', 'body', 10) + * @load('^/common/js/plugins/ckeditor') + * @load('styles.scss', $vars) + * + * @param string $content + * @return string + */ + protected function _convertAssets(string $content): string + { + // Convert XE-style load directives. + $regexp = '#^[\x09\x20]*()[\x09\x20]*$#m'; + $content = preg_replace_callback($regexp, function($match) { + $attrs = self::_getTagAttributes($match[1]); + $attrs['src'] = $attrs['src'] ?? ($attrs['target'] ?? null); + if (!$attrs['src']) return $match[0]; + return self::_escapeVars(self::_generateCodeForAsset($attrs)); + }, $content); + + // Convert Blade-style load directives. + $regexp = '#^[\x09\x20]*@load\x20?\((.+?)\)[\x09\x20]*#sm'; + $content = preg_replace_callback($regexp, function($match) { + $args = array_map('trim', explode(',', $match[1])); + $attrs = self::_arrangeArgumentsForAsset($args); + if (!$attrs['src']) return $match[0]; + return self::_escapeVars(self::_generateCodeForAsset($attrs)); + }, $content); + + return $content; + } + + /** + * Subroutine for determining the role of each argument to @load. + * + * The Blade-style syntax does not have named arguments, so we must rely + * on the position and format of each argument to guess what it is for. + * Fortunately, there are only a handful of valid options for the type, + * media, and index attributes. + * + * @param array $args + * @return array + */ + protected function _arrangeArgumentsForAsset(array $args): array + { + // Assign the path. + $info = []; + if (preg_match('#^([\'"])([^\'"]+)\1$#', array_shift($args) ?? '', $match)) + { + $info['src'] = $match[2]; + } + if (!$info['src']) + { + return []; + } + + // Assign the remaining arguments to respective array keys. + while ($value = array_shift($args)) + { + if (preg_match('#^([\'"])(head|body)\1$#', $value, $match)) + { + $info['type'] = $match[2]; + } + elseif (preg_match('#^([\'"])(screen|print)\1$#', $value, $match)) + { + $info['media'] = $match[2]; + } + elseif (preg_match('#^([\'"])([0-9]+)\1$#', $value, $match)) + { + $info['index'] = $match[2]; + } + elseif (ctype_digit($value)) + { + $info['index'] = $value; + } + else + { + $info['vars'] = $value; + } + } + + return $info; + } + + /** + * Subroutine to generate code for asset loading. + * + * @param array $info + * @return string + */ + protected function _generateCodeForAsset(array $info): string + { + // Determine whether the path is an internal or external link. + $path = $info['src']; + $external = false; + if (preg_match('#^\^#', $path)) + { + $path = './' . ltrim($path, '^/'); + } + elseif (self::_isRelativePath($path)) + { + $path = self::_convertRelativePath($path, './' . $this->template->relative_dirname); + } + else + { + $external = true; + } + + // Determine the type of resource. + if (!$external && str_starts_with($path, './common/js/plugins/')) + { + $restype = 'jsplugin'; + } + elseif (!$external && preg_match('#/lang(\.xml)?$#', $path)) + { + $restype = 'lang'; + } + elseif (preg_match('#\.(css|js|scss|less)($|\?|/)#', $path, $match)) + { + $restype = $match[1]; + } + else + { + $restype = 'unknown'; + } + + // Generate code for each type of asset. + if ($restype === 'jsplugin') + { + if (preg_match('#/common/js/plugins/([^/]+)#', $path, $match)) + { + $plugin_name = $match[1]; + return sprintf('', var_export($plugin_name, true)); + } + else + { + return sprintf('', var_export($path, true)); + } + } + elseif ($restype === 'lang') + { + $lang_dir = preg_replace('#/lang\.xml$#', '', $path); + return sprintf('', var_export($lang_dir, true)); + } + elseif ($restype === 'js') + { + return vsprintf('', [ + var_export($path, true), + var_export($info['type'] ?? '', true), + var_export($external ? $this->source_type : '', true), + var_export(isset($info['index']) ? intval($info['index']) : '', true), + ]); + } + elseif ($restype === 'unknown') + { + return sprintf('', var_export($path, true)); + } + else + { + return vsprintf('', [ + var_export($path, true), + var_export($info['media'] ?? '', true), + var_export($external ? $this->source_type : '', true), + var_export(isset($info['index']) ? intval($info['index']) : '', true), + empty($info['vars']) ? '[]' : self::_convertVariableScope($info['vars']) + ]); + } + } + + /** + * Convert loop and condition directives. + * + * Loops and conditions can be written inside HTML comments (XE-style) + * or without comments (Blade-style). If using the Blade-style syntax, + * each directive must appear in its own line. + * + * It is highly recommended that ending directives match the starting + * directive, e.g. @if ... @endif. However, for compatibility with legacy + * templates, Rhymix will automatically find out which loop you are + * trying to end if you simply write @end. Either way, your loops must + * balance out or you will see 'unexpected end of file' errors. + * + * XE-style syntax: + * + * + * + * Blade-style syntax: + * @if ($cond) + * @endif + * + * @param string $content + * @return string + */ + protected function _convertLoopDirectives(string $content): string + { + // Convert block directives. + $regexp = '#(?:^[\x09\x20]*||[\x09\x20]*$)#sm'; + $content = preg_replace_callback($regexp, function($match) { + + // Collect the necessary information. + $directive = $match[1]; + $args = isset($match[2]) ? self::_convertVariableScope($match[2]) : ''; + $stack = null; + $code = null; + + // If this is an ending directive, find the loop information in the stack. + if (preg_match('#^end(.*)$#', $directive, $m)) + { + $stack = array_pop($this->_stack); + $directive = $m[1] ?: $stack['directive']; + } + + // Handle intermediate directives first. + if ($directive === 'empty' && !$args && !$stack && end($this->_stack)['directive'] === 'forelse') + { + $code = self::$_loopdef['forelse'][1]; + } + + // Single directives. + elseif (isset(self::$_loopdef[$directive]) && count(self::$_loopdef[$directive]) === 1) + { + $code = self::$_loopdef[$directive][0]; + $code = str_contains($code, '%s') ? sprintf($code, $args) : $code; + } + + // Paired directives. + elseif (isset(self::$_loopdef[$directive])) + { + // Starting directive. + if (!$stack) + { + $uniq = substr(sha1($this->template->absolute_path . ':' . $this->_uniq_order++), 0, 14); + $array = ''; + $remainder = ''; + if ($directive === 'foreach' || $directive === 'forelse') + { + if (preg_match('#^(.+?)\sas\s(.+)#is', $args, $m)) + { + $array = trim($m[1]); + $remainder = trim($m[2]); + } + else + { + $array = $args; + $remainder = ''; + } + } + $code = self::$_loopdef[$directive][0]; + $code = strtr($code, ['%uniq' => $uniq, '%array' => $array, '%remainder' => $remainder]); + $code = str_contains($code, '%s') ? sprintf($code, $args) : $code; + $this->_stack[] = [ + 'directive' => $directive, + 'uniq' => $uniq, + 'array' => $array, + 'remainder' => $remainder, + ]; + } + + // Ending directive. + else + { + $code = end(self::$_loopdef[$directive]); + $code = strtr($code, ['%uniq' => $stack['uniq'], '%array' => $stack['array'], '%remainder' => $stack['remainder']]); + $code = str_contains($code, '%s') ? sprintf($code, $args) : $code; + } + } + + // Invalid directives. + else + { + $code = sprintf('trigger_error("Invalid directive: " . %s, \E_USER_WARNING);', var_export($directive, true)); + } + + // Put together the PHP code. + return self::_escapeVars(""); + + }, $content); + + return $content; + } + + /** + * Convert inline directives. + * + * This helps display commonly used attributes conditionally, + * without littering the template with inline @if ... @endif directives. + * The 'cond' attribute usd in XE has been expanded to support + * the shorter 'if' notation, 'when', and 'unless'. + * + * XE-style syntax: + *