var_prefix_str = '$' . $this->var_prefix . '_';
$this->rebuild_delim_cache();
}
public function __destruct() {
// In simple mode we auto print the output
if ($this->simple_mode && !$this->fetch_called) {
print $this->fetch();
}
}
// Assign variables to pass to the TPL
public function assign($key, $val = null) {
// Single item call (assign array at once)
if (is_null($val) && is_array($key)) {
$this->tpl_vars = array_merge($this->tpl_vars, $key);
} else {
$this->tpl_vars[$key] = $val;
}
}
// Convert template blocks in to output strings
public function process_block(string $str, int $char_pos = -1) {
$this->char_pos = $char_pos;
$ld = $this->left_delim;
$rd = $this->right_delim;
$ret = '';
// Simple variable replacement {$foo} or {$foo|default:"123"}
if (str_starts_with($str, $ld . '$') && preg_match($this->var_pattern, $str, $m)) {
$ret = $this->variable_block($m[1]);
// If statement {if $foo}{/if}
} elseif (str_starts_with($str, $this->if_space_tag) && str_ends_with($str, $this->close_if)) {
$ret = $this->if_block($str);
// Foreach {foreach $foo as $x}{/foreach}
} elseif (str_starts_with($str, $this->foreach_tag . ' ') && preg_match($this->foreach_pattern, $str, $m)) {
$ret = $this->foreach_block($m);
// Include {include file='my.stpl' number='99'}
} elseif (str_starts_with($str, $ld . 'include ')) {
$ret = $this->include_block($str);
// Literal {literal}Stuff here{/literal}
} elseif (str_starts_with($str, $this->literal_tag) && preg_match($this->literal_pattern, $str, $m)) {
$ret = $m[1];
// Catch all for other { $num + 3 } type of blocks
} elseif (preg_match($this->catchall_pattern, $str, $m)) {
// Blocks must not have whitespace immediately next to the delimiters
// e.g. {$foo } or { $foo } or { 3 + 4 } are invalid.
$inner = $m[1];
if ($inner !== '' && (ctype_space($inner[0]) || ctype_space($inner[-1]))) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Whitespace not allowed next to delimiters $str in $file on line #$line", 50981);
}
$ret = $this->expression_block($str, $m);
// If it starts with '{' (from above) but does NOT contain a closing tag
} elseif (!str_ends_with($str, $rd)) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unclosed tag $str in $file on line #$line", 45821);
// Something went WAY wrong
} else {
$ret = $str;
}
return $ret;
}
// Break the text up in to tokens/blocks to process by process_block()
public function get_blocks($str) {
$start = 0;
$blocks = [];
$slen = strlen($str);
$ld = $this->left_delim;
$rd = $this->right_delim;
$name_len_if = strlen($this->if_tag);
$name_len_foreach = strlen($this->foreach_tag);
$name_len_literal = strlen($this->literal_tag);
$prev_was_comment = false; // Tracks if previous construct was a comment
// Start looking for blocks at the first delim
$z = strpos($str, $ld);
if ($z === false) { $z = $slen; }
for ($i = $z; $i < $slen; $i++) {
$char = $str[$i];
$is_open = $char === $ld;
$is_closed = $char === $rd;
// If it's not an opening or closing tag we jump ahead to the next delim
if (!$is_open && !$is_closed) {
$next_open = strpos($str, $ld, $i);
if ($next_open === false) { $next_open = $slen; }
$next_close = strpos($str, $rd, $i);
if ($next_close === false) { $next_close = $slen; }
// The next char to look at is the first open/close delim
$i = min($next_open, $next_close) -1;
continue;
}
$has_len = $start != $i;
$is_comment = false;
// Check to see if it's a real {} block
if ($is_open) {
$prev_c = $str[$i - 1];
$next_c = $str[$i + 1];
// If the { is surrounded by whitespace it's not a block.
if (ctype_space($prev_c) && ctype_space($next_c)) {
$is_open = false;
}
if ($next_c === "*") {
$is_comment = true;
}
}
// if it's a "{" then the block is every from the last $start to here
if ($is_open && $has_len) {
$len = $i - $start;
$block = substr($str, $start, $len);
$start = $i;
// Strip leading \n if previous block was a comment (comment line ending)
if ($prev_was_comment) {
$block = $this->ltrim_one($block, "\n");
$prev_was_comment = false;
}
if ($block) {
$blocks[] = [$block, $i];
}
// If it's a "}" it's a closing block that starts at $start
} elseif ($is_closed) {
$len = $i - $start + 1;
$block = substr($str, $start, $len);
// If this block is an opening function tag ({if}, {foreach}, {literal}),
// walk forward to find the matching {/if}, {/foreach}, or {/literal}.
// Nested blocks of the same type are handled by counting open vs close tags.
$m = null;
if (str_starts_with($block, $this->if_tag)) {
$c = $block[$name_len_if] ?? '';
if ($c !== '' && $c !== '_' && !ctype_alnum($c)) { $m = 'if'; }
} elseif (str_starts_with($block, $this->foreach_tag)) {
$c = $block[$name_len_foreach] ?? '';
if ($c !== '' && $c !== '_' && !ctype_alnum($c)) { $m = 'foreach'; }
} elseif (str_starts_with($block, $this->literal_tag)) {
$c = $block[$name_len_literal] ?? '';
if ($c !== '' && $c !== '_' && !ctype_alnum($c)) { $m = 'literal'; }
}
$is_function = $m !== null;
if ($is_function) {
$close_tag = $ld . "/$m" . $rd;
$open_tag_str = $ld . $m;
// Seed open count with occurrences inside the opening tag itself
// (e.g. {if $x eq '{if}'} has two {if substrings)
$open_count = substr_count($block, $open_tag_str);
$close_count = 0;
// Position after the opening tag's closing } — start scanning from here
$last_pos = $i + 1;
// Jump directly to each } instead of walking character-by-character.
// Between consecutive } characters, count open and close tags in that
// segment using substr_count with offset/length. Accumulating incrementally
// avoids rescanning the entire block on every }.
$j = $i + 1;
$found_close = false;
$first_literal_close = null; // For nested {literal} blocks
while ($j < $slen) {
$j = strpos($str, $rd, $j);
if ($j === false) break;
// Preserve the first literal close as a fallback when its payload
// contains raw {literal} text without a matching nested close.
if ($m === 'literal' && $first_literal_close === null) {
$tag_end = substr($str, $j - strlen($close_tag) + 1, strlen($close_tag));
if ($tag_end === $close_tag) {
$first_literal_close = $j;
}
}
$seg_len = $j - $last_pos + 1;
$open_count += substr_count($str, $open_tag_str, $last_pos, $seg_len);
$close_count += substr_count($str, $close_tag, $last_pos, $seg_len);
$last_pos = $j + 1;
// When open and close counts match, verify this } ends the correct
// close tag (not a coincidental } inside another construct)
if ($open_count === $close_count) {
$tmp = substr($str, $start, $j - $start + 1);
if (str_ends_with($tmp, $close_tag)) {
$block = $tmp;
$found_close = true;
break;
}
}
$j++;
}
// No matching close tag was found, so this is an unclosed
// control block. Slurp everything to the end of the string as a
// single unterminated block so it yields 45821 cleanly instead
// of a wrong-code error plus leaked trailing payload.
if (!$found_close) {
// A literal payload may itself begin with {literal} without a
// matching nested close. In that case its first close ends the
// outer literal; a missing close remains an unclosed-tag error.
if ($first_literal_close !== null) {
$block = substr($str, $start, $first_literal_close - $start + 1);
$found_close = true;
} else {
$block = substr($str, $start);
}
}
}
// The block's position in $str is fixed by the walk above; trimming
// the literal content must not shift that, so we advance $start by
// the original (pre-trim) length.
$orig_block_len = strlen($block);
// For a complete {literal} block standing alone on its lines, trim the
// \n that belongs to the tag lines from the literal content, mirroring
// the comment-line handling. Skipped for unclosed blocks (no {/literal}).
if ($m === 'literal' && $found_close) {
$ltag = $this->literal_tag . $rd;
$rtag = $ld . '/literal' . $rd;
$l_len = strlen($ltag);
$r_len = strlen($rtag);
$inner = substr($block, $l_len, strlen($block) - $l_len - $r_len);
$begins_line = ($start === 0) || ($str[$start - 1] === "\n");
$after_pos = $start + strlen($block);
$ends_line = ($after_pos >= $slen) || ($str[$after_pos] === "\n");
if ($begins_line && $inner !== "" && $inner[0] === "\n") {
$inner = substr($inner, 1);
}
if ($ends_line && $inner !== "" && $inner[strlen($inner) - 1] === "\n") {
$inner = substr($inner, 0, -1);
}
$block = $ltag . $inner . $rtag;
}
if ($block) {
$blocks[] = [$block, $i];
}
$prev_was_comment = false; // Non-comment block consumed, reset flag
$start += $orig_block_len;
$i = $start;
}
// If it's a comment we slurp all the chars until the first '*}' and make that the block
if ($is_comment) {
$end = $this->find_ending_tag(substr($str, $start), $this->comment_open, $this->comment_close);
// If we don't find a closing comment tag we add the half-block to the list
// and it gets caught by "invalid block" later
if ($end === false) {
continue;
}
$end += strlen($this->comment_close);
$start += $end;
$i = $start - 1;
$prev_was_comment = true; // So trailing \n (line ending) gets stripped
}
}
// If we're not at the end of the string, add the last block
if ($start < $slen) {
$block = substr($str, $start);
// Strip leading \n if trailing text follows a comment
if ($prev_was_comment) {
$block = $this->ltrim_one($block, "\n");
$prev_was_comment = false;
}
if ($block) {
$blocks[] = [$block, $i];
}
}
// If the *previous* block was an {if} or {foreach} we remove one leading \n
// to maintain parity between input and output whitespace. ^ is the whitespace
// we're removing.
//
// This allows templates like:
//
// {foreach $name as $x}
// {$x}
// {/foreach}^
$prev_is_if = false;
$block_count = count($blocks);
$if_prefix = $ld . 'if';
$for_prefix = $ld . 'for';
for ($i = 0; $i < $block_count; $i++) {
$str = $blocks[$i][0] ?? "";
$cur_is_if = str_starts_with($str, $if_prefix) || str_starts_with($str, $for_prefix);
// A combined if/foreach block (contains closing tag) should only
// trigger trimming of the next block's \n if the payload already
// ends with \n — otherwise the \n is content, not formatting.
if ($cur_is_if && (str_contains($str, $this->close_if) || str_contains($str, $this->close_foreach))) {
$cur_is_if = boolval(preg_match($this->newline_pattern, $str));
}
if ($prev_is_if) {
$blocks[$i][0] = $this->ltrim_one($str, "\n");
}
$prev_is_if = $cur_is_if;
}
return $blocks;
}
// This is just a wrapper function because early versions of Sluz used parse() instead of fetch()
public function parse($tpl_file = "") {
$ret = $this->fetch($tpl_file);
return $ret;
}
// Wrapper function to make us more compatible with Smarty
public function display($tpl_file = "") {
print $this->fetch($tpl_file);
}
// Specify a path to the .stpl file, or pass nothing to let sluz 'guess'
// Guess is 'tpls/[scriptname_minus_dot_php].stpl
public function fetch($tpl_file = "", $parent = null) {
if (!$this->php_file) {
$this->php_file = $this->get_php_file();
$this->php_file_dir = dirname($this->php_file);
}
// We use ABSOLUTE paths here because this may be called in the destructor which has a cwd() of '/'
if (!$tpl_file) {
$tpl_file = $this->guess_tpl_file($this->php_file);
}
$parent_tpl = $parent ?? $this->parent_tpl;
if (!empty($parent_tpl)) {
$this->assign("__CHILD_TPL", $tpl_file);
$tpl_file = $parent_tpl;
}
$str = $this->get_tpl_content($tpl_file);
$blocks = $this->get_blocks($str);
$html = $this->process_blocks($blocks);
$this->fetch_called = true;
return $html;
}
// Parse a string (not a file)
public function parse_string($tpl_str) {
$blocks = $this->get_blocks($tpl_str);
$html = $this->process_blocks($blocks);
return $html;
}
// Guess the TPL filename based on the PHP file
public function guess_tpl_file($php_file) {
$base = basename($php_file);
$tpl_name = preg_replace('/\.php$/', '.stpl', $base);
$ret = "tpls/$tpl_name";
return $ret;
}
// Find the calling parent PHP file (from the perspective of the sluz class)
public function get_php_file() {
$x = debug_backtrace();
$last = count($x) - 1;
$ret = $x[$last]['file'] ?? "";
return $ret;
}
// Turn an array of blocks into output HTML
private function process_blocks(array $blocks) {
$html = '';
foreach ($blocks as $x) {
$block = $x[0];
$first_char = ($block[0] ?? "") === $this->left_delim;
// If the first char is a { it's something we need to process
if ($first_char) {
$char_pos = $x[1];
$html .= $this->process_block($block, $char_pos);
// It's a static text block so we just append it
} elseif ($block) {
$html .= $block;
}
}
return $html;
}
// Load the template file into a string
private function get_tpl_content($tpl_file) {
$tf = $this->tpl_file = $tpl_file;
// If we're in simple mode and we have a __halt_compiler() we can assume inline mode
$inline_simple = $this->simple_mode && $this->get_inline_content($this->php_file);
$is_inline = ($tpl_file === SLUZ_INLINE) || $inline_simple;
if ($this->php_file_dir) {
$tf = $this->php_file_dir . "/$tf";
}
if ($is_inline) {
$str = $this->get_inline_content($this->php_file);
} elseif ($tf && !is_readable($tf)) {
return $this->error_out("Unable to load template file $tf",42280);
} elseif ($tf) {
$str = file_get_contents($tf);
}
if (empty($str)) { $str = ""; }
return $str;
}
// Get the text after __halt_compiler()
private function get_inline_content($file) {
$str = file_get_contents($file);
$offset = stripos($str, '__halt_compiler();');
if ($offset === false) {
return null;
}
$ret = substr($str, $offset + 18);
return $ret;
}
// Find the include TPL in the {include} string
function extract_include_file($str) {
// {include file='foo.stpl'}
if (preg_match("/\s(file=)(['\"].+?['\"])/", $str, $m)) {
$xstr = $this->convert_variables_in_string($m[2]);
$ret = $this->peval($xstr);
// {include 'foo.stpl'} - unofficial
} elseif (preg_match("/\s(['\"].+?['\"])/", $str, $m)) {
$xstr = $this->convert_variables_in_string($m[1]);
$ret = $this->peval($xstr);
} else {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unable to find a file in include block $str in $file on line #$line", 68493);
}
$this->inc_tpl_file = $ret;
return $ret;
}
// Extract data from an array in the form of $foo.key.baz
public function array_dive(string $needle, array $haystack) {
// Do a simple hash lookup first before we dive deep (we may get lucky)
$x = $haystack[$needle] ?? null;
if ($x) {
return $x;
}
// Fast path: no dots means the direct lookup above was the only option.
// Return whatever it found (even falsy values like 0, "", false) without
// the overhead of explode() + loop.
if (!str_contains($needle, '.')) {
return $haystack[$needle] ?? null;
}
// Split at the periods
$parts = explode(".", $needle);
// Loop through each level of the hash looking for elem
$arr = $haystack;
foreach ($parts as $elem) {
$arr = $arr[$elem] ?? null;
// If we don't find anything stop looking
if ($arr === null) {
break;
}
}
// If we find a scalar it's the end of the line, anything else is just
// another branch, so it doesn't count as finding something
if (is_scalar($arr) || is_array($arr)) {
$ret = $arr;
} else {
$ret = null;
}
return $ret;
}
// Convert $cust.name.first -> $cust['name']['first'] and $num.0.3 -> $num[0][3]
private function convert_variables_in_string($str) {
// If there are no dollars signs it's not a variable string, nothing to do
if (!str_contains($str, '$')) {
return $str;
}
// Fast path: no dots means just prefix variables, skip callback overhead
if (!str_contains($str, '.')) {
$str = preg_replace('/\$(\w+)/', '$' . $this->var_prefix . '_$1', $str);
return $str;
}
// Process flat arrays in the test like $cust.name or $array[3]
$callback = array($this, 'dot_to_bracket_callback');
$str = preg_replace_callback('/(\$\w[\w\.]*)/', $callback, $str);
return $str;
}
// Build an evalable string from a variable string
private function dot_to_bracket_callback($m) {
$str = $m[1];
$parts = explode(".", $str);
$ret = array_shift($parts);
$ret = "$" . $this->var_prefix . '_' . substr($ret,1);
foreach ($parts as $x) {
if (is_numeric($x)) {
$ret .= "[" . $x . "]";
} else {
$ret .= "['" . $x . "']";
}
}
return $ret;
}
// Spit out an error message
public function error_out($msg, int $err_num) {
$style = "
.s_error {
font-family : sans;
color : #842029;
padding : 0.8em;
border-radius: 4px;
margin-bottom: 8px;
background : #f8d7da;
border : 1px solid #f5c2c7;
max-width : 70%;
margin : auto;
min-width : 370px;
}
.s_error_head {
margin-top : 0;
color : white;
text-shadow: 0px 0px 7px gray;
}
.s_error_num { margin-top: 1em; }
.s_error_file {
margin-top : 2em;
padding-top: 0.5em;
font-size : .8em;
border-top : 1px solid gray;
}
.s_error code {
padding : .2rem .4rem;
font-size : 1.1em;
border-radius : .2rem;
background-color: #dad5d5;
color : #1a1a1a;
border : 1px solid #c2c2c2;
}
";
if ($this->in_unit_test) {
return "ERROR-$err_num";
}
$d = debug_backtrace();
$file = $d[0]['file'] ?? "";
$line = $d[0]['line'] ?? 0;
$title = "Sluz error #$err_num";
$body = "
$file #$line$func in $file on line #$line", 47204);
}
try {
$pre = call_user_func_array($func, $params);
} catch (Exception $e) {
$msg = "Exception: " . $e->getMessage();
return $this->error_out($msg, 79134);
} catch (TypeError $e) {
$msg = "TypeError: " . $e->getMessage();
return $this->error_out($msg, 58200);
}
}
}
$ret = $pre;
} else {
$ret = $this->array_dive($str, $this->tpl_vars) ?? "";
}
// Array used as a scalar should silently convert to a string
if (is_array($ret)) {
return 'Array';
}
// Auto-escape HTML unless the modifier chain already contains escape or raw
if ($this->escape_html) {
$has_escape = ($ppos !== false) && (str_contains($mod, 'escape') || str_contains($mod, 'raw'));
if (!$has_escape) {
$ret = htmlspecialchars((string) $ret, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
return $ret;
}
// Find the line/column based on char offset in a file
private function get_char_location($pos, $tpl_file) {
// If we're in an {include} the tpl is that temporarily
if ($this->inc_tpl_file) {
$tpl_file = $this->inc_tpl_file;
}
$str = $this->get_tpl_content($tpl_file);
// Error catching...
if ($pos < 0) {
return [-1, -1, $tpl_file];
}
$line = 1;
$col = 0;
for ($i = 0; $i < strlen($str); $i++) {
$col++;
$char = $str[$i];
if ($char === "\n") {
$line++;
$col = 0;
}
if ($pos === $i) {
$ret = [$line, $col, $tpl_file];
return $ret;
}
}
if ($pos === strlen($str)) {
return [$line, $col, $tpl_file];
}
return [-1, -1, $tpl_file];
}
// Fast-path evaluator for simple variable conditions and binary comparisons
// in {if}/{elseif} conditions, e.g. "$user.active", "$var > 10",
// "$user.role == 'admin'", "$x != $y".
// This avoids the extract()+eval() overhead (which recompiles PHP
// source on every call) for the overwhelming majority of real-world
// template conditions. Returns null if the condition doesn't match
// this strict shape, signalling the caller to fall back to peval().
private function try_fast_condition($cond) {
$cond = trim($cond);
// Standalone variables, including dotted paths
static $var_re = '/^(!)?\$(\w[\w.]*)$/';
if (preg_match($var_re, $cond, $vm)) {
$value = $this->array_dive($vm[2], $this->tpl_vars);
if ($vm[1]) {
return !$value;
} else {
return (bool) $value;
}
}
// One variable, one comparison operator, and one scalar operand.
static $re = '/^\$(\w[\w.]*)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/s';
if (!preg_match($re, $cond, $m)) {
return null;
}
$left = $this->array_dive($m[1], $this->tpl_vars);
$op = $m[2];
$rhs = trim($m[3]);
// Resolve the right-hand side: quoted string literal, numeric
// literal, or another $var(.path)*. Anything more complex
// (function calls, arithmetic, boolean operators, parens) is
// rejected so it falls back to the safe eval() path.
if ($rhs === '') {
return null;
}
$first = $rhs[0];
$last = $rhs[-1];
if (($first === "'" || $first === '"') && $first === $last && strlen($rhs) >= 2) {
$inner = substr($rhs, 1, -1);
// Reject if the inner content still has the quote char (could
// be an escaped/complex string) or a backslash (escape seq).
if (str_contains($inner, $first) || str_contains($inner, '\\')) {
return null;
}
$right = $inner;
} elseif (is_numeric($rhs)) {
$right = $rhs + 0;
} elseif ($first === '$' && preg_match('/^\$(\w[\w.]*)$/', $rhs, $rm)) {
$right = $this->array_dive($rm[1], $this->tpl_vars);
} else {
return null;
}
switch ($op) {
case '==': return $left == $right;
case '!=': return $left != $right;
case '===': return $left === $right;
case '!==': return $left !== $right;
case '>': return $left > $right;
case '>=': return $left >= $right;
case '<': return $left < $right;
case '<=': return $left <= $right;
}
return null;
}
// Parse an if statement
private function if_block($str) {
$ld = $this->left_delim;
// If it's a simple {if $name}Output{/if} we can save a lot of
// time parsing detailed rules
// i.e. there is no {else} or {elseif}
$is_simple = (strpos($str, $this->else_start, strlen($ld) + 6) === false);
if ($is_simple) {
preg_match($this->if_simple_pattern, $str, $m);
$cond = $m[1] ?? "";
$payload = $m[2] ?? "";
// This makes input -> output whitespace more correct
$payload = $this->ltrim_one($payload, "\n");
// Try the fast path (simple comparisons)
$fast = $this->try_fast_condition($cond);
if ($fast !== null) {
$pass = $fast;
} else {
$err = 0;
$pass = $this->peval($this->convert_variables_in_string($cond), $err);
if ($err) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unable to evaluate condition $cond in $file on line #$line", 18933);
}
}
if (!$pass) {
return "";
}
$blocks = $this->get_blocks($payload);
return $this->process_blocks($blocks);
}
// Complex path: walk the tokens once, evaluating each condition as soon
// as its payload is accumulated, and short-circuit on the first match.
$toks = $this->get_tokens($str);
$num = count($toks);
$nested = 0;
$cur = '';
$cur_cond = null;
$first = true;
// Process each token in order, tracking {if} nesting depth and accumulating
// the current branch's payload until a control boundary (or {/if}) is hit
for ($i = 0; $i < $num; $i++) {
$item = $toks[$i];
if (str_starts_with($item, $this->if_tag)) { $nested++; }
if ($item === $this->close_if) { $nested--; }
// Determine if this token is an if-control boundary ({if}, {elseif}, {else})
// at nesting level 1. Nested {if}/{/if} pairs are treated as payload content.
$is_ctrl = false;
$next_cond = null;
if ($nested === 1) {
if ($item === $this->else_tag) {
$is_ctrl = true;
$next_cond = true;
} elseif (str_starts_with($item, $this->if_space_tag)) {
$is_ctrl = true;
$next_cond = trim(substr($item, strlen($ld) + 3, -strlen($this->right_delim)));
} elseif (str_starts_with($item, $this->elseif_tag)) {
$is_ctrl = true;
$next_cond = trim(substr($item, strlen($ld) + 7, -strlen($this->right_delim)));
}
}
// The last token always acts as a control boundary so its
// preceding payload is captured
if ($i === $num - 1) {
$is_ctrl = true;
}
// At a control boundary, test the just-completed payload's condition;
// on the first match, process and return immediately
if ($is_ctrl) {
if (!$first) {
if ($cur_cond === true) {
$passed = true;
} else {
$fast = $this->try_fast_condition((string) $cur_cond);
if ($fast !== null) {
$passed = $fast;
} else {
$err = 0;
$passed = (bool) $this->peval($this->convert_variables_in_string((string) $cur_cond), $err);
if ($err) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unable to evaluate condition $cur_cond in $file on line #$line", 18933);
}
}
}
if ($passed) {
$cur = $this->ltrim_one($cur, "\n");
$blocks = $this->get_blocks($cur);
return $this->process_blocks($blocks);
}
}
$first = false;
$cur = '';
$cur_cond = $next_cond;
} else {
$cur .= $item;
}
}
return "";
}
// Parse an include block
private function include_block($str) {
// Include blocks may modify tpl vars, so we save them here
$save = $this->tpl_vars;
$inc_tpl = $this->extract_include_file($str);
// In unit-test mode error_out() returns "ERROR-" instead of
// printing and exiting, so a missing/blank include file comes back
// as an error string rather than a filename. Propagate it rather
// than treating the error text as a template to load.
if (str_starts_with($inc_tpl, 'ERROR-')) {
return $inc_tpl;
}
if ($this->php_file_dir) {
$inc_tpl = $this->php_file_dir . "/$inc_tpl";
}
// Extra variables to include sub templates
if (preg_match_all("/(\w+)=(['\"](.+?)['\"])/", $str, $m)) {
for ($i = 0; $i < count($m[0]); $i++) {
$key = $m[1][$i] ?? "";
$val = $m[2][$i] ?? "";
// We skip the file='header.stpl' option
if ($key === 'file') { continue; }
$val = $this->convert_variables_in_string($val);
$val = $this->peval($val);
$this->assign($key, $val);
}
}
if (!is_file($inc_tpl) || !is_readable($inc_tpl)) {
$this->inc_tpl_file = null; // Clear temp override so this error displays correctly
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unable to load include template $inc_tpl in $file on line #$line", 18485);
}
$str = file_get_contents($inc_tpl);
$blocks = $this->get_blocks($str);
$ret = $this->process_blocks($blocks);
// Restore the TPL vars to pre 'include' state
$this->tpl_vars = $save;
$this->inc_tpl_file = null; // Clear temp override
return $ret;
}
// Remove ONE \n from the beginning of a string
function ltrim_one(string $str, $char) {
if (isset($str[0]) && $str[0] === $char) {
return substr($str, 1);
}
return $str;
}
// Parse a foreach block
private function foreach_block($m) {
// Resolve simple and dotted sources directly instead of using peval().
$src = $this->array_dive(substr($m[1], 1), $this->tpl_vars); // src array
$okey = $m[2]; // orig key
$oval = $m[4]; // orig val
$payload = $m[5]; // code block to parse on iteration
$payload = $this->ltrim_one($payload, "\n"); // Input -> Output \n parity
$blocks = $this->get_blocks($payload);
// If $src isn't an array we convert it to one so foreach doesn't barf
if (isset($src) && !is_array($src)) {
$src = [$src];
// This prevents an E_WARNING on null (but doesn't output anything)
} elseif (is_null($src)) {
$src = [];
}
// Save the current values so we can restore them later
$save = $this->tpl_vars;
// Check to see if the block contains any of the FOREACH special vars
$need_meta = str_contains($payload, '$__FOREACH');
$ret = '';
$idx = 0;
$last = count($src) - 1;
foreach ($src as $key => $val) {
if ($need_meta) {
$this->tpl_vars['__FOREACH_FIRST'] = ($idx === 0);
$this->tpl_vars['__FOREACH_LAST'] = ($idx === $last);
$this->tpl_vars['__FOREACH_INDEX'] = $idx;
}
// This is a key/val pair: foreach $key => $val
if ($oval) {
$this->tpl_vars[$okey] = $key;
$this->tpl_vars[$oval] = $val;
// This is: foreach $array as $item
} else {
$this->tpl_vars[$okey] = $val;
}
$ret .= $this->process_blocks($blocks);
$idx++;
}
// Restore the TPL vars to the version before the {foreach} started
$this->tpl_vars = $save;
return $ret;
}
// Parse a simple expression block — the catch-all for {…} blocks that
// aren't variables, if/foreach/include/literal. Covers math ({3 + 4}),
// string ops ({"hello" . " world"}), and bare variable expressions.
//
// $str is the full block e.g. "{$x + 3}", $m[1] is the inner content "$x + 3".
private function expression_block($str, $m) {
$blk = $m[1] ?? "";
// Cheap pre-filter: skip eval entirely if the block contains none of
// the characters that could form a valid PHP expression (no quotes,
// digits, $, or parens). These are bare words like {foo} or stray
// braces — nothing evaluable.
if (!preg_match("/[\"\d$(]/", $blk)) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unknown block type $str in $file on line #$line", 73467);
}
// Convert variables in the block to their real values
$after = $this->convert_variables_in_string($blk);
$ret = $this->peval($after, $err);
// peval sets $err to -1 on a ParseError; also reject non-scalar
// results (null, false, objects, arrays) — only strings and
// numbers are valid template output.
if ($err || !(is_string($ret) || is_numeric($ret))) {
list($line, $col, $file) = $this->get_char_location($this->char_pos, $this->tpl_file);
return $this->error_out("Unknown tag $str in $file on line #$line", 18933);
}
return $ret;
}
// Find the position of the closing tag in a string. This *IS* nesting aware
function find_ending_tag($haystack, $open_tag, $close_tag) {
// Do a quick check up to the FIRST closing tag to see if we find it
$pos = strpos($haystack, $close_tag);
$substr = substr($haystack,0, $pos);
$open_count = substr_count($substr, $open_tag);
if ($open_count === 1) {
return $pos;
}
// This is the FULL search, where we keep adding chunks of the string looking for
// the close tag, and then checking if the open tags match the close tags
// We skip ahead past the first match above because we know there isn't a match in
// the first X characters
$close_len = strlen($close_tag);
$offset = $pos + $close_len;
// We only go five deep... this prevents endless loops
// No one should need more than five levels of nested comments
for ($h = 0; $h < 5; $h++) {
$pos = strpos($haystack, $close_tag, $offset);
if ($pos === false) {
return false;
}
$substr = substr($haystack, 0, $pos + 2);
// If we find the end delimiter and the open/closed tag count is the same
$open_count = substr_count($substr, $open_tag);
$close_count = substr_count($substr, $close_tag);
if ($open_count === $close_count) {
return $pos;
}
$offset = $pos + $close_len;
}
return false;
}
// Break up a string into tokens: pieces of {} and the text between them
function get_tokens($str) {
return preg_split($this->tokens_pattern, $str, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
}
// Get/Set parent tpl
function parent_tpl($tpl) {
if (isset($tpl)) {
$this->parent_tpl = $tpl;
return $this->parent_tpl;
} else {
return $this->parent_tpl;
}
}
// Rebuild all cached delimiter-dependent values
private function rebuild_delim_cache() {
$ld = $this->left_delim;
$rd = $this->right_delim;
$this->dlre_cached = preg_quote($ld, '/');
$this->drre_cached = preg_quote($rd, '/');
$ldre = $this->dlre_cached;
$rdre = $this->drre_cached;
$this->if_tag = $ld . 'if';
$this->foreach_tag = $ld . 'foreach';
$this->literal_tag = $ld . 'literal';
$this->close_if = $ld . '/if' . $rd;
$this->close_foreach = $ld . '/foreach' . $rd;
$this->else_tag = $ld . 'else' . $rd;
$this->elseif_tag = $ld . 'elseif ';
$this->else_start = $ld . 'else'; // Matches '{else' and '{elseif'
$this->if_space_tag = $ld . 'if ';
$this->comment_open = $ld . '*';
$this->comment_close = '*' . $rd;
$this->var_pattern = '/^' . $ldre . '\$(\w[\w.]*(?:\|.*)?)' . $rdre . '$/';
$this->foreach_pattern = '/^' . $ldre . 'foreach (\$\w[\w.]*) as \$(\w+)( => \$(\w+))?' . $rdre . '(.+)' . $ldre . '\/foreach' . $rdre . '$/s';
$this->literal_pattern = '/^' . $ldre . 'literal' . $rdre . '(.+)' . $ldre . '\/literal' . $rdre . '$/s';
$this->catchall_pattern = '/^' . $ldre . '(.+)' . $rdre . '$/s';
$this->if_simple_pattern = '/' . $ldre . 'if (.+?)' . $rdre . '(.+)' . $ldre . '\/if' . $rdre . '/s';
$this->tokens_pattern = '/(' . $ldre . '[^' . $rdre . ']+' . $rdre . ')/';
$this->newline_pattern = '/\n' . $ldre . '\/\w+' . $rdre . '$/s';
}
// Enable/disable auto-escaping of HTML in variable output
public function setEscapeHtml($enable = true) {
$this->escape_html = (bool) $enable;
return $this;
}
// Set alternate single-character delimiters (default is { / })
// Comment delimiters are derived: left_delim + '*' and '*' + right_delim
public function set_delimiters($left, $right) {
if (strlen($left) !== 1 || strlen($right) !== 1) {
throw new \InvalidArgumentException("Delimiters must be single characters");
}
if ($left === $right) {
throw new \InvalidArgumentException("Left and right delimiters must be different");
}
$this->left_delim = $left;
$this->right_delim = $right;
$this->rebuild_delim_cache();
}
}
// Safely encode a value to prevent XSS. Supports 'html' (default), 'url', and 'js'.
function escape($str, $type = 'html') {
$str = (string) $str;
if ($type === 'html') {
return htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
} elseif ($type === 'url') {
return rawurlencode($str);
} elseif ($type === 'js') {
return json_encode($str, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
} else {
return "Unknown escape type '$type' #65491";
}
}
// Return value unchanged — used with setEscapeHtml() to opt out of auto-escaping
function raw($str) {
return $str;
}
// This function is *OUTSIDE* of the class so it can be called separately without
// instantiating the class
function sluz($one, $two = null) {
static $s;
if (!$s) {
$s = new sluz();
$d = debug_backtrace();
$last = array_shift($d);
$file = $last['file'];
$s->enable_simple_mode($file);
}
$s->assign($one, $two);
return $s;
}
// vim: tabstop=4 shiftwidth=4 noexpandtab autoindent softtabstop=4