| 1 |
<?php |
| 2 |
/** |
| 3 |
* Twig-subset template engine for Widget Builder templates. |
| 4 |
* |
| 5 |
* Extracted from Dynamic_Widget so the widget runtime and the editor preview |
| 6 |
* render identically. Previously the preview only substituted {{ placeholders }} |
| 7 |
* and printed {% if %} / {% for %} tags as literal text. |
| 8 |
* |
| 9 |
* Control types drive per-value escaping and are injected rather than read off |
| 10 |
* an Elementor widget, so the engine works in an AJAX context too. |
| 11 |
* |
| 12 |
* @package MasterAddons |
| 13 |
* @subpackage WidgetBuilder |
| 14 |
*/ |
| 15 |
|
| 16 |
namespace MasterAddons\Inc\Admin\WidgetBuilder; |
| 17 |
|
| 18 |
if (!defined('ABSPATH')) { |
| 19 |
exit; |
| 20 |
} |
| 21 |
|
| 22 |
class Widget_Template_Engine { |
| 23 |
|
| 24 |
/** @var array control name => control type, for output escaping. */ |
| 25 |
private $control_types = []; |
| 26 |
|
| 27 |
/** |
| 28 |
* @param array $control_types Map of control name => type (text, url, wysiwyg, ...). |
| 29 |
*/ |
| 30 |
public function __construct(array $control_types = []) { |
| 31 |
$this->control_types = array_change_key_case($control_types, CASE_LOWER); |
| 32 |
} |
| 33 |
|
| 34 |
/** Control type for a template variable; defaults to text (esc_html). */ |
| 35 |
private function control_type($name) { |
| 36 |
$key = strtolower(trim((string) $name)); |
| 37 |
return isset($this->control_types[$key]) ? $this->control_types[$key] : 'text'; |
| 38 |
} |
| 39 |
|
| 40 |
/* ------------------------------------------------------------------ * |
| 41 |
* Twig-syntax template engine (safe subset; no eval, no compiled PHP). |
| 42 |
* Supports: {{ var }} {{ var.prop }} {{ var|raw }} {{ var|upper }} |
| 43 |
* {% if expr %} {% elseif expr %} {% else %} {% endif %} |
| 44 |
* {% for item in list %} ... {% endfor %} |
| 45 |
* Conditions: == != > < >= <= and or not plus bare truthiness. |
| 46 |
* All output is escaped per control type unless the |raw filter is used. |
| 47 |
* ------------------------------------------------------------------ */ |
| 48 |
|
| 49 |
/** Render a template string against the variable context. */ |
| 50 |
public function render($template, $context) { |
| 51 |
$template = (string) $template; |
| 52 |
if ('' === $template) { |
| 53 |
return ''; |
| 54 |
} |
| 55 |
$tokens = $this->tokenize_template($template); |
| 56 |
$pos = 0; |
| 57 |
$ast = $this->parse_template($tokens, $pos, []); |
| 58 |
return $this->eval_nodes($ast, $context); |
| 59 |
} |
| 60 |
|
| 61 |
/** Split a template into text / {{ output }} / {% tag %} tokens. */ |
| 62 |
private function tokenize_template($template) { |
| 63 |
$parts = preg_split('/(\{%.*?%\}|\{\{.*?\}\})/s', $template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); |
| 64 |
$tokens = []; |
| 65 |
foreach ($parts as $part) { |
| 66 |
if (preg_match('/^\{%\s*(.*?)\s*%\}$/s', $part, $m)) { |
| 67 |
$inner = trim($m[1]); |
| 68 |
$space = strpos($inner, ' '); |
| 69 |
$keyword = (false === $space) ? $inner : substr($inner, 0, $space); |
| 70 |
$expr = (false === $space) ? '' : trim(substr($inner, $space + 1)); |
| 71 |
$tokens[] = ['type' => 'tag', 'kw' => $keyword, 'expr' => $expr]; |
| 72 |
} elseif (preg_match('/^\{\{\s*(.*?)\s*\}\}$/s', $part, $m)) { |
| 73 |
$tokens[] = ['type' => 'out', 'expr' => trim($m[1])]; |
| 74 |
} else { |
| 75 |
$tokens[] = ['type' => 'text', 'value' => $part]; |
| 76 |
} |
| 77 |
} |
| 78 |
return $tokens; |
| 79 |
} |
| 80 |
|
| 81 |
/** Recursive-descent parse into an AST. Stops (without consuming) on a $stops keyword. */ |
| 82 |
private function parse_template($tokens, &$pos, $stops) { |
| 83 |
$nodes = []; |
| 84 |
$count = count($tokens); |
| 85 |
while ($pos < $count) { |
| 86 |
$tok = $tokens[$pos]; |
| 87 |
if ('text' === $tok['type']) { |
| 88 |
$nodes[] = ['text', $tok['value']]; |
| 89 |
$pos++; |
| 90 |
continue; |
| 91 |
} |
| 92 |
if ('out' === $tok['type']) { |
| 93 |
$nodes[] = ['out', $tok['expr']]; |
| 94 |
$pos++; |
| 95 |
continue; |
| 96 |
} |
| 97 |
// tag |
| 98 |
$kw = $tok['kw']; |
| 99 |
if (in_array($kw, $stops, true)) { |
| 100 |
return $nodes; // leave $pos on the stop tag for the caller |
| 101 |
} |
| 102 |
if ('if' === $kw) { |
| 103 |
$pos++; |
| 104 |
$branches = []; |
| 105 |
$cond = $tok['expr']; |
| 106 |
while (true) { |
| 107 |
$body = $this->parse_template($tokens, $pos, ['elseif', 'else', 'endif']); |
| 108 |
$branches[] = [$cond, $body]; |
| 109 |
if ($pos >= $count) { |
| 110 |
break; |
| 111 |
} |
| 112 |
$next = $tokens[$pos]; |
| 113 |
if ('endif' === $next['kw']) { |
| 114 |
$pos++; |
| 115 |
break; |
| 116 |
} |
| 117 |
if ('elseif' === $next['kw']) { |
| 118 |
$cond = $next['expr']; |
| 119 |
$pos++; |
| 120 |
continue; |
| 121 |
} |
| 122 |
if ('else' === $next['kw']) { |
| 123 |
$cond = '__else__'; |
| 124 |
$pos++; |
| 125 |
continue; |
| 126 |
} |
| 127 |
break; |
| 128 |
} |
| 129 |
$nodes[] = ['if', $branches]; |
| 130 |
continue; |
| 131 |
} |
| 132 |
if ('for' === $kw) { |
| 133 |
$pos++; |
| 134 |
$body = $this->parse_template($tokens, $pos, ['endfor']); |
| 135 |
if ($pos < $count && 'endfor' === $tokens[$pos]['kw']) { |
| 136 |
$pos++; |
| 137 |
} |
| 138 |
$nodes[] = ['for', $tok['expr'], $body]; |
| 139 |
continue; |
| 140 |
} |
| 141 |
// stray close/else with no opener -> skip |
| 142 |
$pos++; |
| 143 |
} |
| 144 |
return $nodes; |
| 145 |
} |
| 146 |
|
| 147 |
/** Evaluate an AST node list to a string. */ |
| 148 |
private function eval_nodes($nodes, $context) { |
| 149 |
$out = ''; |
| 150 |
foreach ($nodes as $node) { |
| 151 |
switch ($node[0]) { |
| 152 |
case 'text': |
| 153 |
$out .= $node[1]; |
| 154 |
break; |
| 155 |
case 'out': |
| 156 |
$out .= $this->render_output($node[1], $context); |
| 157 |
break; |
| 158 |
case 'if': |
| 159 |
foreach ($node[1] as $branch) { |
| 160 |
if ('__else__' === $branch[0] || $this->eval_condition($branch[0], $context)) { |
| 161 |
$out .= $this->eval_nodes($branch[1], $context); |
| 162 |
break; |
| 163 |
} |
| 164 |
} |
| 165 |
break; |
| 166 |
case 'for': |
| 167 |
if (preg_match('/^(\w+)\s+in\s+(.+)$/s', trim($node[1]), $m)) { |
| 168 |
$list = $this->resolve_value(trim($m[2]), $context); |
| 169 |
if (is_array($list)) { |
| 170 |
foreach ($list as $row) { |
| 171 |
$scope = $context; |
| 172 |
$scope[$m[1]] = $row; |
| 173 |
$out .= $this->eval_nodes($node[2], $scope); |
| 174 |
} |
| 175 |
} |
| 176 |
} |
| 177 |
break; |
| 178 |
} |
| 179 |
} |
| 180 |
return $out; |
| 181 |
} |
| 182 |
|
| 183 |
/** Resolve an expression to its raw value: literal, number, bool, or dotted var path. */ |
| 184 |
private function resolve_value($expr, $context) { |
| 185 |
$expr = trim($expr); |
| 186 |
if ('' === $expr) { |
| 187 |
return null; |
| 188 |
} |
| 189 |
$first = $expr[0]; |
| 190 |
$last = substr($expr, -1); |
| 191 |
if (('"' === $first && '"' === $last) || ("'" === $first && "'" === $last)) { |
| 192 |
return substr($expr, 1, -1); |
| 193 |
} |
| 194 |
if (is_numeric($expr)) { |
| 195 |
return $expr + 0; |
| 196 |
} |
| 197 |
if ('true' === $expr) { |
| 198 |
return true; |
| 199 |
} |
| 200 |
if ('false' === $expr) { |
| 201 |
return false; |
| 202 |
} |
| 203 |
if ('null' === $expr) { |
| 204 |
return null; |
| 205 |
} |
| 206 |
$value = $context; |
| 207 |
foreach (explode('.', $expr) as $part) { |
| 208 |
if (is_array($value) && array_key_exists($part, $value)) { |
| 209 |
$value = $value[$part]; |
| 210 |
} else { |
| 211 |
return null; |
| 212 |
} |
| 213 |
} |
| 214 |
return $value; |
| 215 |
} |
| 216 |
|
| 217 |
/** Evaluate a boolean condition (or / and / not / comparison / truthiness). */ |
| 218 |
private function eval_condition($expr, $context) { |
| 219 |
$expr = trim($expr); |
| 220 |
if ('__else__' === $expr || 'true' === $expr) { |
| 221 |
return true; |
| 222 |
} |
| 223 |
if ('' === $expr || 'false' === $expr) { |
| 224 |
return false; |
| 225 |
} |
| 226 |
// or (lowest precedence) |
| 227 |
$parts = preg_split('/\s+or\s+/', $expr); |
| 228 |
if (count($parts) > 1) { |
| 229 |
foreach ($parts as $part) { |
| 230 |
if ($this->eval_condition($part, $context)) { |
| 231 |
return true; |
| 232 |
} |
| 233 |
} |
| 234 |
return false; |
| 235 |
} |
| 236 |
// and |
| 237 |
$parts = preg_split('/\s+and\s+/', $expr); |
| 238 |
if (count($parts) > 1) { |
| 239 |
foreach ($parts as $part) { |
| 240 |
if (!$this->eval_condition($part, $context)) { |
| 241 |
return false; |
| 242 |
} |
| 243 |
} |
| 244 |
return true; |
| 245 |
} |
| 246 |
// not |
| 247 |
if (preg_match('/^not\s+(.+)$/s', $expr, $m)) { |
| 248 |
return !$this->eval_condition($m[1], $context); |
| 249 |
} |
| 250 |
// comparison (longest operators tried first via alternation order) |
| 251 |
if (preg_match('/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/s', $expr, $m)) { |
| 252 |
return $this->compare( |
| 253 |
$this->resolve_value($m[1], $context), |
| 254 |
$this->resolve_value($m[3], $context), |
| 255 |
$m[2] |
| 256 |
); |
| 257 |
} |
| 258 |
// bare truthiness |
| 259 |
return $this->truthy($this->resolve_value($expr, $context)); |
| 260 |
} |
| 261 |
|
| 262 |
/** Compare two resolved values; numeric when both numeric, else string. */ |
| 263 |
private function compare($a, $b, $op) { |
| 264 |
if (is_numeric($a) && is_numeric($b)) { |
| 265 |
$a += 0; |
| 266 |
$b += 0; |
| 267 |
} else { |
| 268 |
$a = (string) $a; |
| 269 |
$b = (string) $b; |
| 270 |
} |
| 271 |
switch ($op) { |
| 272 |
case '==': |
| 273 |
return $a == $b; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- template equality is intentionally loose |
| 274 |
case '!=': |
| 275 |
return $a != $b; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- template inequality is intentionally loose |
| 276 |
case '>': |
| 277 |
return $a > $b; |
| 278 |
case '<': |
| 279 |
return $a < $b; |
| 280 |
case '>=': |
| 281 |
return $a >= $b; |
| 282 |
case '<=': |
| 283 |
return $a <= $b; |
| 284 |
} |
| 285 |
return false; |
| 286 |
} |
| 287 |
|
| 288 |
/** Twig/Handlebars truthiness: '', '0', 0, null, false, [] are falsy. */ |
| 289 |
private function truthy($value) { |
| 290 |
if (null === $value || false === $value) { |
| 291 |
return false; |
| 292 |
} |
| 293 |
if (is_array($value)) { |
| 294 |
return !empty($value); |
| 295 |
} |
| 296 |
$string = (string) $value; |
| 297 |
return '' !== $string && '0' !== $string; |
| 298 |
} |
| 299 |
|
| 300 |
/** Render a {{ output }} expression: resolve, apply filters, escape per type. */ |
| 301 |
private function render_output($expr, $context) { |
| 302 |
$segments = array_map('trim', explode('|', trim($expr))); |
| 303 |
$base = array_shift($segments); |
| 304 |
$value = $this->resolve_value($base, $context); |
| 305 |
|
| 306 |
if (is_array($value)) { |
| 307 |
$value = isset($value['url']) ? $value['url'] : ''; |
| 308 |
} |
| 309 |
$value = (string) $value; |
| 310 |
|
| 311 |
$raw = false; |
| 312 |
foreach ($segments as $filter) { |
| 313 |
switch ($filter) { |
| 314 |
case 'raw': |
| 315 |
$raw = true; |
| 316 |
break; |
| 317 |
case 'e': |
| 318 |
case 'escape': |
| 319 |
$raw = false; |
| 320 |
break; |
| 321 |
case 'upper': |
| 322 |
$value = strtoupper($value); |
| 323 |
break; |
| 324 |
case 'lower': |
| 325 |
$value = strtolower($value); |
| 326 |
break; |
| 327 |
case 'trim': |
| 328 |
$value = trim($value); |
| 329 |
break; |
| 330 |
} |
| 331 |
} |
| 332 |
if ($raw) { |
| 333 |
return $value; |
| 334 |
} |
| 335 |
$type = strtolower($this->control_type($base)); |
| 336 |
return $this->escape_value($value, $type); |
| 337 |
} |
| 338 |
|
| 339 |
private function escape_value($value, $type) { |
| 340 |
switch ($type) { |
| 341 |
case 'wysiwyg': |
| 342 |
case 'code': |
| 343 |
return wp_kses_post($value); |
| 344 |
case 'url': |
| 345 |
return esc_url($value); |
| 346 |
default: |
| 347 |
return esc_html($value); |
| 348 |
} |
| 349 |
} |
| 350 |
} |
| 351 |
|