PluginProbe
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits / 3.1.9
Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits v3.1.9
3.2.2 3.2.3 3.2.1 3.2.0 3.1.9 3.1.8 3.1.7 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.9 trunk 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.3 1.1.4 1.1.5 All 174 releases
master-addons / inc / admin / widget-builder / class-widget-template-engine.php

class-widget-template-engine.php in Master Addons for Elementor – Elementor Addons, Widgets, Mega Menu Builder, Popup Builder, Widget Builder & Template Kits 3.1.9, at inc/admin/widget-builder/class-widget-template-engine.php

351 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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