regex -> index() -> recursive descent -> LIME LALR(1) * * Homepage: http://yourcmc.ru/wiki/VMX::Template * License: GNU GPLv3 or later * Author: Vitaliy Filippov, 2006-2020 * Version: V3 (LALR), 2020-01-01 * * The template engine is split into two parts: * (1) This file - always used when running templates * (2) VMXTemplateCompiler.php - used only when compiling new templates */ /** * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ # TODO For perl version - rewrite it and prevent auto-vivification on a.b if (!defined('TS_UNIX')) { // Global timestamp format constants define('TS_UNIX', 0); define('TS_DB', 1); define('TS_DB_DATE', 2); define('TS_MW', 3); define('TS_EXIF', 4); define('TS_ORACLE', 5); define('TS_ISO_8601', 6); define('TS_RFC822', 7); } class VMXTemplate { // Loaded template class names public static $loadedClasses = []; static $Mon, $mon, $Wday; static $cache_type = NULL; static $cache = array(); static $safe_tags = 'div|blockquote|span|a|b|i|u|p|h1|h2|h3|h4|h5|h6|strike|strong|small|big|blink|center|ol|pre|sub|sup|font|br|table|tr|td|th|tbody|tfoot|thead|tt|ul|li|em|img|marquee|cite'; // Timestamp format constants const TS_UNIX = 0; const TS_DB = 1; const TS_DB_DATE = 2; const TS_MW = 3; const TS_EXIF = 4; const TS_ORACLE = 5; const TS_ISO_8601 = 6; const TS_RFC822 = 7; // Version of code classes, saved into static $version const CODE_VERSION = 5; // Data passed to the template var $tpldata = array(); // Parent 'VMXTemplate' object for compiled templates // parse_anything() functions are always called on $this->parent var $parent = NULL; // Failed-to-load filenames, saved to skip them during the request var $failed = array(); // Search path for template functions (filenames indexed by function name) var $function_search_path = array(); // Options, compiler objects var $options, $compiler; /** * Constructor * * @param array $options Options */ function __construct($options) { $this->options = new VMXTemplateOptions($options); } /** * Clear template data */ function clear() { $this->tpldata = array(); return true; } /** * Shortcut for $this->vars() */ function assign_vars($new = NULL, $value = NULL) { $this->vars($new, $value); } /** * Set template data value/values. * $obj->vars($key, $value); * or * $obj->vars(array(key => value, ...)); */ function vars($new = NULL, $value = NULL) { if (is_array($new)) { $this->tpldata = array_merge($this->tpldata, $new); } elseif ($new && $value !== NULL) { $this->tpldata[$new] = $value; } } /*** Cache support - XCache/APC/eAccelerator ***/ static function cache_check_type() { if (is_null(self::$cache_type)) { if (function_exists('xcache_get')) self::$cache_type = 'x'; elseif (function_exists('apc_store')) self::$cache_type = 'a'; elseif (function_exists('eaccelerator_get')) self::$cache_type = 'e'; else self::$cache_type = ''; } } static function cache_get($key) { self::cache_check_type(); if (!array_key_exists($key, self::$cache)) { if (self::$cache_type == 'x') self::$cache[$key] = xcache_get($key); elseif (self::$cache_type == 'a') self::$cache[$key] = apc_fetch($key); elseif (self::$cache_type == 'e') self::$cache[$key] = eaccelerator_get($key); } return @self::$cache[$key]; } static function cache_del($key) { self::cache_check_type(); unset(self::$cache[$key]); if (self::$cache_type == 'x') xcache_unset($key); elseif (self::$cache_type == 'a') apc_delete($key); elseif (self::$cache_type == 'e') eaccelerator_rm($key); } static function cache_set($key, $value) { self::cache_check_type(); self::$cache[$key] = $value; if (self::$cache_type == 'x') xcache_set($key, $value); elseif (self::$cache_type == 'a') apc_store($key, $value); elseif (self::$cache_type == 'e') eaccelerator_put($key, $value); } /*** Parse functions ***/ /** * Normal (main) parse function. * Use it to run the template. * * @param string $filename Template filename * @param array $vars Optional data, will override $this->tpldata */ function parse($filename, $vars = NULL) { return $this->parse_real($filename, NULL, 'main', $vars); } /** * Call template block (= macro/function) * * @param string $filename Template filename * @param string $function Function name * @param array $vars Optional data */ function exec_from($filename, $function, $vars = NULL) { return $this->parse_real($filename, NULL, $function, $vars); } /** * Should not be used without great need. * Run template passed as argument. */ function parse_inline($code, $vars = NULL) { return $this->parse_real(NULL, $code, 'main', $vars); } /** * Should not be used without great need. * Execute a function from the code passed as argument. */ function exec_from_inline($code, $function, $vars = NULL) { return $this->parse_real(NULL, $code, $function, $vars); } /** * parse_real variant that does not require $vars to be an lvalue * and does not run filters on output */ protected function parse_discard($fn, $inline, $func, $vars = NULL) { return $this->parse_real($fn, $inline, $func, $vars, false); } /** * "Real" parse function, handles all parse_*() */ protected function parse_real($fn, $inline, $func, &$vars = NULL, $run_filters = true) { if (!$fn) { if (!strlen($inline)) return ''; $class = 'Template_X'.md5($inline); if (!class_exists($class)) { if (!($file = $this->compile($inline, ''))) return NULL; include $file; } } else { if (substr($fn, 0, 1) != '/') $fn = $this->options->root.$fn; /* Don't reload already loaded classes - optimal for multiple parse() calls. But if we would like to reload templates during ONE request some day... */ $class = 'Template_'.md5($fn); if (!class_exists($class)) { if (isset($this->failed[$fn])) { // Fail recorded, don't retry until next request return NULL; } if (!($text = $this->loadfile($fn))) { $e = error_get_last(); $this->options->error("couldn't load template file '$fn': ".$e['message'], true); $this->failed[$fn] = true; return NULL; } if (!($file = $this->compile($text, $fn))) { $this->failed[$fn] = true; return NULL; } $r = include($file); if ($r !== 1) { $this->options->error("error including compiled template for '$fn'", true); $this->failed[$fn] = true; return NULL; } if (!class_exists($class) || !isset($class::$version) || $class::$version < self::CODE_VERSION) { // Force recompile $file = $this->compile($text, $fn, true); $this->options->error( "Invalid or stale cache '$file' for template '$fn'. Caused by one of:". " template upgrade (error should go away on next run), two templates with same content (change or merge), or an MD5 collision :)", true ); return NULL; } foreach ($class::$functions as $loaded_function => $args) { // FIXME Do it better // Remember functions during file loading $this->function_search_path[$loaded_function][] = array($fn, $args); } } } if (!isset($class::$functions[$func])) { $this->options->error("No function '$func' found in ".($fn ? "template $fn" : 'inline template'), true); return NULL; } $func = "fn_$func"; $tpl = new $class($this); if ($vars) { $tpl->tpldata = &$vars; } $old = error_reporting(); if ($old & E_NOTICE) { error_reporting($old & ~E_NOTICE); } $t = $tpl->$func(); if ($old & E_NOTICE) { error_reporting($old); } if ($run_filters && $this->options->filters) { $filters = $this->options->filters; if (is_callable($filters) || is_string($filters) && is_callable(array(__CLASS__, "filter_$filters"))) { $filters = array($filters); } foreach ($filters as $w) { if (is_string($w) && is_callable(array(__CLASS__, "filter_$w"))) { $w = array(__CLASS__, "filter_$w"); } elseif (!is_callable($w)) { continue; } call_user_func_array($w, array(&$t)); } } return $t; } /** * Translate template file line number from stack frame $frame (taken from debug_backtrace()) */ public function translateLine(&$frame) { if (isset(VMXTemplate::$loadedClasses[$frame['file']])) { $class = VMXTemplate::$loadedClasses[$frame['file']]; if (isset($class::$smap)) { $l = $frame['line']; $s = 0; $e = count($class::$smap); while ($e > $s+1) { if ($l < $class::$smap[($e+$s)>>1][0]) $e = ($e+$s)>>1; else $s = ($e+$s)>>1; } $frame['file'] = $class::$template_filename; $frame['line'] = $class::$smap[$s][1]; } } if (!empty($frame['class']) && substr($frame['class'], 0, 9) == 'Template_') { $class = $frame['class']; $frame['class'] = $class::$template_filename; $frame['type'] = '->'; if (substr($frame['function'], 0, 3) == 'fn_') $frame['function'] = substr($frame['function'], 3); } } /** * Load file (with caching) * * @param string $fn Filename */ function loadfile($fn) { $load = false; if (!($text = self::cache_get("U$fn")) || $this->options->reload) { $mtime = @stat($fn); $mtime = $mtime[9]; if (!$text) { $load = true; } else { $ctime = self::cache_get("T$fn"); if ($ctime < $mtime) { $load = true; } } } // Reload if file changed if ($load) { if ($fp = @fopen($fn, "rb")) { fseek($fp, 0, SEEK_END); $t = ftell($fp); fseek($fp, 0, SEEK_SET); $text = fread($fp, $t); fclose($fp); } else { return NULL; } // Different keys may expire separately, but that's not a problem here self::cache_set("T$fn", $mtime); self::cache_set("U$fn", $text); } return $text; } /** * Compile code into a file and return its filename. * This file, evaluated, will create the "Template_XXX" class * * $file = $this->compile($code, $fn); * require $file; */ function compile($code, $fn, $reload = false) { $md5 = md5($code); $file = $this->options->cache_dir . 'tpl' . $md5 . '.php'; if (file_exists($file) && !$reload) { return $file; } if (!$fn) { // Mock filename for inline code $func_ns = 'X' . $md5; $c = debug_backtrace(); $c = $c[2]; $fn = '(inline template at '.$c['file'].':'.$c['line'].')'; } else { $func_ns = md5($fn); } if ($this->options->strip_space) self::filter_strip_space($code); if (!$this->compiler) { require_once(dirname(__FILE__).'/VMXTemplateCompiler.php'); $this->compiler = new VMXTemplateCompiler($this->options); } $compiled = $this->compiler->parse_all($code, $fn, $func_ns); $compiled .= "VMXTemplate::\$loadedClasses['".addcslashes(realpath(dirname($file)).'/'.basename($file), '\\\'')."'] = 'Template_$func_ns';\n"; if (!file_put_contents($file, $compiled)) { throw new VMXTemplateException("Failed writing $file"); } return $file; } /*** Built-in filters ***/ /** * Strips space from the beginning and ending of each line */ static function filter_strip_space(&$text) { $text = preg_replace('/^[ \t]+/m', '', $text); $text = preg_replace('/[ \t]+$/m', '', $text); } /*** Function implementations ***/ /** * Call template block / "function" from the template where it was defined */ function call_block($block, $args, $errorinfo) { if (isset($this->function_search_path[$block])) { // FIXME maybe do it better! $fn = $this->function_search_path[$block][0][0]; return $this->parse_real($fn, NULL, $block, $args, false); } throw new VMXTemplateException("Unknown block '$block'$errorinfo"); } function call_block_list($block, $args, $errorinfo) { if (isset($this->function_search_path[$block])) { $fun = $this->function_search_path[$block][0]; $args = array_combine($fun[1], array_pad(array_slice($args, 0, count($fun[1])), count($fun[1]), NULL)); return $this->parse_real($fun[0], NULL, $block, $args, false); } throw new VMXTemplateException("Unknown block or function '$block'$errorinfo"); } // No-op, just returns the single argument. Needed to workaround ($expression)['key'] and ($expression)->m() issues. static function noop($a) { return $a; } // Guess if the array is associative based on the first key (for performance) static function is_assoc($a) { reset($a); return $a && !is_int(key($a)); } // Merge all scalar and list arguments into one list static function merge_to_array() { $args = func_get_args(); $aa = (array) array_shift($args); if (self::is_assoc($aa)) $aa = array($aa); foreach ($args as $a) { if (is_array($a) && !self::is_assoc($a)) foreach ($a as $v) $aa[] = $v; else $aa[] = $a; } return $aa; } // Returns count of elements for arrays and 0 for others static function array_count($a) { if (is_array($a)) return count($a); return 0; } // Perlish OR operator - returns first true value static function perlish_or() { $a = func_get_args(); $last = array_pop($a); foreach ($a as $v) if ($v) return $v; return $last; } // Call a function function exec_call($f, $sub, $args) { if (is_callable($sub)) return call_user_func_array($sub, $args); $this->parent->options->error("Unknown function: '$f'"); return NULL; } // Extract values from an array by modulus of their indexes // exec_subarray_divmod([], 2) // exec_subarray_divmod([], 2, 1) static function exec_subarray_divmod($array, $div, $mod) { if (!$div || !is_array($array)) return $array; if (!$mod) $mod = 0; $i = 0; $r = array(); foreach ($array as $k => $v) if (($i % $div) == $mod) $r[$k] = $v; return $r; } // Executes subst() static function exec_subst($str) { $args = func_get_args(); $str = preg_replace_callback( '/(? 'key', value => 'value' } static function exec_pairs($array, $kf = 'key', $vf = 'value') { $r = array(); foreach ($array as $k => $v) $r[] = array($kf => $k, $vf => $v); return $r; } // Limit string length, cut it on space boundary and add '...' if length is over static function strlimit($str, $maxlen, $dots = '...') { if (!$maxlen || $maxlen < 1 || strlen($str) <= $maxlen) return $str; $str = substr($str, 0, $maxlen); $p = strrpos($str, ' '); if (!$p || ($pt = strrpos($str, "\t")) > $p) $p = $pt; if ($p) $str = substr($str, 0, $p); return $str . $dots; } // UTF-8 (mb_internal_encoding() really) variant of strlimit() static function mb_strlimit($str, $maxlen, $dots = '...') { if (!$maxlen || $maxlen < 1 || mb_strlen($str) <= $maxlen) return $str; $str = mb_substr($str, 0, $maxlen); $p = mb_strrpos($str, ' '); if (!$p || ($pt = mb_strrpos($str, "\t")) > $p) $p = $pt; if ($p) $str = mb_substr($str, 0, $p); return $str . $dots; } // UTF-8 lcfirst() static function mb_lcfirst($str) { return mb_strtolower(mb_substr($str, 0, 1)) . mb_substr($str, 1); } // UTF-8 ucfirst() static function mb_ucfirst($str) { return mb_strtoupper(mb_substr($str, 0, 1)) . mb_substr($str, 1); } // Replace tags with whitespace static function strip_tags($str, $allowed = false) { $allowed = $allowed ? '(?!/?('.$allowed.'))' : ''; return preg_replace('#(<'.$allowed.'/?[a-z][a-z0-9-]*(\s+[^<>]*)?>\s*)+#is', ' ', $str); } // Ignore result function void($a) { return ''; } // Select one of 3 plural forms for russian language static function plural_ru($count, $one, $few, $many) { $sto = $count % 100; if ($sto >= 10 && $sto <= 20) return $many; switch ($count % 10) { case 1: return $one; case 2: case 3: case 4: return $few; } return $many; } // Limited-edition timestamp parser static function timestamp($ts = 0, $format = 0) { if (!self::$Mon) { self::$Mon = explode(' ', 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'); self::$mon = array_reverse(explode(' ', 'jan feb mar apr may jun jul aug sep oct nov dec')); self::$Wday = explode(' ', 'Sun Mon Tue Wed Thu Fri Sat'); } if (!strcmp(intval($ts), $ts)) { // TS_UNIX or Epoch if (!$ts) $ts = time(); } elseif (preg_match('/^\D*(\d{4,})\D*(\d{2})\D*(\d{2})\D*(?:(\d{2})\D*(\d{2})\D*(\d{2})\D*([\+\- ]\d{2}\D*)?)?$/s', $ts, $m)) { // TS_DB, TS_DB_DATE, TS_MW, TS_EXIF, TS_ISO_8601 $ts = mktime(0+@$m[4], 0+@$m[5], 0+@$m[6], $m[2], $m[3], $m[1]); } elseif (preg_match('/^\s*(\d\d?)-(...)-(\d\d(?:\d\d)?)\s*(\d\d)\.(\d\d)\.(\d\d)/s', $ts, $m)) { // TS_ORACLE $ts = mktime($m[4], $m[5], $m[6], $mon[strtolower($m[2])]+1, intval($m[1]), $m[3] < 100 ? $m[3]+1900 : $m[3]); } elseif (preg_match('/^\s*..., (\d\d?) (...) (\d{4,}) (\d\d):(\d\d):(\d\d)\s*([\+\- ]\d\d)\s*$/s', $ts, $m)) { // TS_RFC822 $ts = mktime($m[4], $m[5], $m[6], $mon[strtolower($m[2])]+1, intval($m[1]), $m[3]); } else { // Bogus value, return NULL return NULL; } if (!$format) { // TS_UNIX return $ts; } elseif ($format == self::TS_MW) { return strftime("%Y%m%d%H%M%S", $ts); } elseif ($format == self::TS_DB) { return strftime("%Y-%m-%d %H:%M:%S", $ts); } elseif ($format == self::TS_DB_DATE) { return strftime("%Y-%m-%d", $ts); } elseif ($format == self::TS_ISO_8601) { return strftime("%Y-%m-%dT%H:%M:%SZ", $ts); } elseif ($format == self::TS_EXIF) { return strftime("%Y:%m:%d %H:%M:%S", $ts); } elseif ($format == self::TS_RFC822) { $l = localtime($ts); return strftime($Wday[$l[6]].", %d ".$Mon[$l[4]]." %Y %H:%M:%S %z", $ts); } elseif ($format == self::TS_ORACLE) { $l = localtime($ts); return strftime("%d-".$Mon[$l[4]]."-%Y %H.%M.%S %p", $ts); } return $ts; } } /** * Template exception classes */ class VMXTemplateException extends Exception {} /** * Options class */ class VMXTemplateOptions { var $begin_code = ''; // instruction end var $begin_subst = '{'; // substitution start (may be turned off via false) var $end_subst = '}'; // substitution end (may be turned off via false) var $no_code_subst = false; // do not substitute expressions in instructions var $eat_code_line = true; // remove the "extra" lines which contain instructions only var $root = '.'; // directory with templates var $cache_dir = false; // compiled templates cache directory var $reload = 1; // 0 means to not check for new versions of cached templates var $filters = array(); // filter to run on output of every template var $use_utf8 = true; // use UTF-8 for all string operations on template variables var $raise_error = false; // die() on fatal template errors var $log_error = false; // send errors to standard error output var $print_error = false; // print fatal template errors var $strip_space = false; // strip spaces from beginning and end of each line var $auto_escape = false; // "safe mode" (try 's' for HTML) - automatically escapes substituted // values via this functions if not escaped explicitly var $compiletime_functions = array(); // custom compile-time functions (code generators) // Logged errors (not an option) var $input_filename; var $errors; function __construct($options = array()) { $this->set($options); $this->errors = array(); } function set($options) { foreach ($options as $k => $v) { if (isset($this->$k)) { $this->$k = $v; } } if (!$this->begin_subst || !$this->end_subst) { $this->begin_subst = false; $this->end_subst = false; $this->no_code_subst = false; } $this->cache_dir = preg_replace('!([^/])/*$!s', '\1/', $this->cache_dir); if (!is_writable($this->cache_dir)) { throw new VMXTemplateException('VMXTemplate: cache_dir='.$this->cache_dir.' is not writable'); } $this->root = preg_replace('!([^/])/*$!s', '\1/', $this->root); } function __destruct() { if ($this->print_error && $this->errors && PHP_SAPI != 'cli') { print '
'. 'VMXTemplate errors:'; $fp = fopen("php://stderr", 'a'); fputs($fp, "VMXTemplate errors:\n".implode("\n", $this->errors)); fclose($fp); } } /** * Log an error or a warning */ function error($e, $fatal = false) { $this->errors[] = $e; if ($this->raise_error && $fatal) die("VMXTemplate error: $e"); if ($this->log_error) error_log("VMXTemplate error: $e"); elseif ($this->print_error && PHP_SAPI == 'cli') print("VMXTemplate error: $e\n"); } }