1 | <?php |
||||||
2 | |||||||
3 | /** |
||||||
4 | * This file is part of Blitz PHP framework. |
||||||
5 | * |
||||||
6 | * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]> |
||||||
7 | * |
||||||
8 | * For the full copyright and license information, please view |
||||||
9 | * the LICENSE file that was distributed with this source code. |
||||||
10 | */ |
||||||
11 | |||||||
12 | namespace BlitzPHP\View; |
||||||
13 | |||||||
14 | use BlitzPHP\Exceptions\ViewException; |
||||||
15 | use BlitzPHP\View\Adapters\NativeAdapter; |
||||||
16 | use ParseError; |
||||||
17 | |||||||
18 | /** |
||||||
19 | * Class for parsing pseudo-vars |
||||||
20 | */ |
||||||
21 | class Parser extends NativeAdapter |
||||||
22 | { |
||||||
23 | /** |
||||||
24 | * Left delimiter character for pseudo vars |
||||||
25 | */ |
||||||
26 | public string $leftDelimiter = '{'; |
||||||
27 | |||||||
28 | /** |
||||||
29 | * Right delimiter character for pseudo vars |
||||||
30 | */ |
||||||
31 | public string $rightDelimiter = '}'; |
||||||
32 | |||||||
33 | /** |
||||||
34 | * Left delimiter characters for conditionals |
||||||
35 | */ |
||||||
36 | protected string $leftConditionalDelimiter = '{'; |
||||||
37 | |||||||
38 | /** |
||||||
39 | * Right delimiter characters for conditionals |
||||||
40 | */ |
||||||
41 | protected string $rightConditionalDelimiter = '}'; |
||||||
42 | |||||||
43 | /** |
||||||
44 | * Stores extracted noparse blocks. |
||||||
45 | */ |
||||||
46 | protected array $noparseBlocks = []; |
||||||
47 | |||||||
48 | /** |
||||||
49 | * Stores any plugins registered at run-time. |
||||||
50 | * |
||||||
51 | * @var array<string, callable|list<string>|string> |
||||||
0 ignored issues
–
show
Documentation
Bug
introduced
by
![]() |
|||||||
52 | */ |
||||||
53 | protected array $plugins = []; |
||||||
54 | |||||||
55 | /** |
||||||
56 | * Stores the context for each data element |
||||||
57 | * when set by `setData` so the context is respected. |
||||||
58 | */ |
||||||
59 | protected array $dataContexts = []; |
||||||
60 | |||||||
61 | /** |
||||||
62 | * {@inheritDoc} |
||||||
63 | */ |
||||||
64 | public function __construct(protected array $config, $viewPathLocator = null, protected bool $debug = BLITZ_DEBUG) |
||||||
65 | { |
||||||
66 | // Ensure user plugins override core plugins. |
||||||
67 | $this->plugins = $config['plugins'] ?? []; |
||||||
68 | |||||||
69 | parent::__construct($config, $viewPathLocator, $debug); |
||||||
70 | } |
||||||
71 | |||||||
72 | /** |
||||||
73 | * Parse a template |
||||||
74 | * |
||||||
75 | * Parses pseudo-variables contained in the specified template view, |
||||||
76 | * replacing them with any data that has already been set. |
||||||
77 | */ |
||||||
78 | public function render(string $view, ?array $options = null, ?bool $saveData = null): string |
||||||
79 | { |
||||||
80 | $start = microtime(true); |
||||||
81 | if ($saveData === null) { |
||||||
82 | $saveData = $this->saveData; |
||||||
83 | } |
||||||
84 | |||||||
85 | if ('' === $ext = pathinfo($view, PATHINFO_EXTENSION)) { |
||||||
86 | $view .= '.php'; |
||||||
87 | $ext = 'php'; |
||||||
88 | } |
||||||
89 | |||||||
90 | $cacheName = $options['cache_name'] ?? str_replace('.' . $ext, '', $view); |
||||||
0 ignored issues
–
show
Are you sure
$ext of type array|string can be used in concatenation ?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
91 | |||||||
92 | // Was it cached? |
||||||
93 | if (isset($options['cache']) && ($output = cache($cacheName))) { |
||||||
94 | $this->logPerformance($start, microtime(true), $view); |
||||||
0 ignored issues
–
show
It seems like
$start can also be of type string ; however, parameter $start of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() It seems like
microtime(true) can also be of type string ; however, parameter $end of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
95 | |||||||
96 | return $output; |
||||||
0 ignored issues
–
show
|
|||||||
97 | } |
||||||
98 | |||||||
99 | $file = $this->viewPath . $view; |
||||||
100 | |||||||
101 | if (! is_file($file)) { |
||||||
102 | $fileOrig = $file; |
||||||
103 | $file = $this->locator->locateFile($view, 'Views', $ext); |
||||||
0 ignored issues
–
show
It seems like
$ext can also be of type array ; however, parameter $ext of BlitzPHP\Contracts\Autol...Interface::locateFile() does only seem to accept string , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
104 | |||||||
105 | // locateFile will return an empty string if the file cannot be found. |
||||||
106 | if (empty($file)) { |
||||||
107 | throw ViewException::invalidFile($fileOrig); |
||||||
108 | } |
||||||
109 | } |
||||||
110 | |||||||
111 | if ($this->tempData === null) { |
||||||
112 | $this->tempData = $this->data; |
||||||
113 | } |
||||||
114 | |||||||
115 | $template = file_get_contents($file); |
||||||
116 | $output = $this->parse($template, $this->tempData, $options); |
||||||
117 | $this->logPerformance($start, microtime(true), $view); |
||||||
118 | |||||||
119 | if ($saveData) { |
||||||
120 | $this->data = $this->tempData; |
||||||
121 | } |
||||||
122 | |||||||
123 | $output = $this->decorate($output); |
||||||
124 | |||||||
125 | // Should we cache? |
||||||
126 | if (isset($options['cache'])) { |
||||||
127 | cache()->save($cacheName, $output, (int) $options['cache']); |
||||||
128 | } |
||||||
129 | $this->tempData = null; |
||||||
130 | |||||||
131 | return $output; |
||||||
132 | } |
||||||
133 | |||||||
134 | /** |
||||||
135 | * Parse a String |
||||||
136 | * |
||||||
137 | * Parses pseudo-variables contained in the specified string, |
||||||
138 | * replacing them with any data that has already been set. |
||||||
139 | */ |
||||||
140 | public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string |
||||||
141 | { |
||||||
142 | $start = microtime(true); |
||||||
143 | if ($saveData === null) { |
||||||
144 | $saveData = $this->saveData; |
||||||
145 | } |
||||||
146 | |||||||
147 | if ($this->tempData === null) { |
||||||
148 | $this->tempData = $this->data; |
||||||
149 | } |
||||||
150 | |||||||
151 | $output = $this->parse($template, $this->tempData, $options); |
||||||
152 | |||||||
153 | $this->logPerformance($start, microtime(true), $this->excerpt($template)); |
||||||
0 ignored issues
–
show
It seems like
microtime(true) can also be of type string ; however, parameter $end of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() It seems like
$start can also be of type string ; however, parameter $start of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
154 | |||||||
155 | if ($saveData) { |
||||||
156 | $this->data = $this->tempData; |
||||||
157 | } |
||||||
158 | |||||||
159 | $this->tempData = null; |
||||||
160 | |||||||
161 | return $output; |
||||||
162 | } |
||||||
163 | |||||||
164 | /** |
||||||
165 | * Sets several pieces of view data at once. |
||||||
166 | * In the Parser, we need to store the context here |
||||||
167 | * so that the variable is correctly handled within the |
||||||
168 | * parsing itself, and contexts (including raw) are respected. |
||||||
169 | * |
||||||
170 | * @param string|null $context The context to escape it for: html, css, js, url, raw |
||||||
171 | * If 'raw', no escaping will happen |
||||||
172 | */ |
||||||
173 | public function setData(array $data = [], ?string $context = null): static |
||||||
174 | { |
||||||
175 | if ($context !== null && $context !== '' && $context !== '0') { |
||||||
176 | foreach ($data as $key => &$value) { |
||||||
177 | if (is_array($value)) { |
||||||
178 | foreach ($value as &$obj) { |
||||||
179 | $obj = $this->objectToArray($obj); |
||||||
180 | } |
||||||
181 | } else { |
||||||
182 | $value = $this->objectToArray($value); |
||||||
183 | } |
||||||
184 | |||||||
185 | $this->dataContexts[$key] = $context; |
||||||
186 | } |
||||||
187 | } |
||||||
188 | |||||||
189 | $this->tempData ??= $this->data; |
||||||
190 | $this->tempData = array_merge($this->tempData, $data); |
||||||
191 | |||||||
192 | return $this; |
||||||
193 | } |
||||||
194 | |||||||
195 | /** |
||||||
196 | * Parse a template |
||||||
197 | * |
||||||
198 | * Parses pseudo-variables contained in the specified template, |
||||||
199 | * replacing them with the data in the second param |
||||||
200 | * |
||||||
201 | * @param array $options Future options |
||||||
202 | */ |
||||||
203 | protected function parse(string $template, array $data = [], ?array $options = null): string |
||||||
0 ignored issues
–
show
The parameter
$options is not used and could be removed.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||||
204 | { |
||||||
205 | if ($template === '') { |
||||||
206 | return ''; |
||||||
207 | } |
||||||
208 | |||||||
209 | // Remove any possible PHP tags since we don't support it |
||||||
210 | // and parseConditionals needs it clean anyway... |
||||||
211 | $template = str_replace(['<?', '?>'], ['<?', '?>'], $template); |
||||||
212 | |||||||
213 | $template = $this->parseComments($template); |
||||||
214 | $template = $this->extractNoparse($template); |
||||||
215 | |||||||
216 | // Replace any conditional code here so we don't have to parse as much |
||||||
217 | $template = $this->parseConditionals($template); |
||||||
218 | |||||||
219 | // Handle any plugins before normal data, so that |
||||||
220 | // it can potentially modify any template between its tags. |
||||||
221 | $template = $this->parsePlugins($template); |
||||||
222 | |||||||
223 | // Parse stack for each parse type (Single and Pairs) |
||||||
224 | $replaceSingleStack = []; |
||||||
225 | $replacePairsStack = []; |
||||||
226 | |||||||
227 | // loop over the data variables, saving regex and data |
||||||
228 | // for later replacement. |
||||||
229 | foreach ($data as $key => $val) { |
||||||
230 | $escape = true; |
||||||
231 | |||||||
232 | if (is_array($val)) { |
||||||
233 | $escape = false; |
||||||
234 | $replacePairsStack[] = [ |
||||||
235 | 'replace' => $this->parsePair($key, $val, $template), |
||||||
236 | 'escape' => $escape, |
||||||
237 | ]; |
||||||
238 | } else { |
||||||
239 | $replaceSingleStack[] = [ |
||||||
240 | 'replace' => $this->parseSingle($key, (string) $val), |
||||||
241 | 'escape' => $escape, |
||||||
242 | ]; |
||||||
243 | } |
||||||
244 | } |
||||||
245 | |||||||
246 | // Merge both stacks, pairs first + single stacks |
||||||
247 | // This allows for nested data with the same key to be replaced properly |
||||||
248 | $replace = array_merge($replacePairsStack, $replaceSingleStack); |
||||||
249 | |||||||
250 | // Loop over each replace array item which |
||||||
251 | // holds all the data to be replaced |
||||||
252 | foreach ($replace as $replaceItem) { |
||||||
253 | // Loop over the actual data to be replaced |
||||||
254 | foreach ($replaceItem['replace'] as $pattern => $content) { |
||||||
255 | $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']); |
||||||
256 | } |
||||||
257 | } |
||||||
258 | |||||||
259 | return $this->insertNoparse($template); |
||||||
260 | } |
||||||
261 | |||||||
262 | /** |
||||||
263 | * Parse a single key/value, extracting it |
||||||
264 | */ |
||||||
265 | protected function parseSingle(string $key, string $val): array |
||||||
266 | { |
||||||
267 | $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') |
||||||
268 | . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' |
||||||
269 | . $this->rightDelimiter . '#ums'; |
||||||
270 | |||||||
271 | return [$pattern => $val]; |
||||||
272 | } |
||||||
273 | |||||||
274 | /** |
||||||
275 | * Parse a tag pair |
||||||
276 | * |
||||||
277 | * Parses tag pairs: {some_tag} string... {/some_tag} |
||||||
278 | */ |
||||||
279 | protected function parsePair(string $variable, array $data, string $template): array |
||||||
280 | { |
||||||
281 | // Holds the replacement patterns and contents |
||||||
282 | // that will be used within a preg_replace in parse() |
||||||
283 | $replace = []; |
||||||
284 | |||||||
285 | // Find all matches of space-flexible versions of {tag}{/tag} so we |
||||||
286 | // have something to loop over. |
||||||
287 | preg_match_all( |
||||||
288 | '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' . |
||||||
289 | $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us', |
||||||
290 | $template, |
||||||
291 | $matches, |
||||||
292 | PREG_SET_ORDER |
||||||
293 | ); |
||||||
294 | |||||||
295 | /* |
||||||
296 | * Each match looks like: |
||||||
297 | * |
||||||
298 | * $match[0] {tag}...{/tag} |
||||||
299 | * $match[1] Contents inside the tag |
||||||
300 | */ |
||||||
301 | foreach ($matches as $match) { |
||||||
302 | // Loop over each piece of $data, replacing |
||||||
303 | // its contents so that we know what to replace in parse() |
||||||
304 | $str = ''; // holds the new contents for this tag pair. |
||||||
305 | |||||||
306 | foreach ($data as $row) { |
||||||
307 | // Objects that have a `toArray()` method should be |
||||||
308 | // converted with that method (i.e. Entities) |
||||||
309 | if (is_object($row) && method_exists($row, 'toArray')) { |
||||||
310 | $row = $row->toArray(); |
||||||
311 | } |
||||||
312 | // Otherwise, cast as an array and it will grab public properties. |
||||||
313 | elseif (is_object($row)) { |
||||||
314 | $row = (array) $row; |
||||||
315 | } |
||||||
316 | |||||||
317 | $temp = []; |
||||||
318 | $pairs = []; |
||||||
319 | $out = $match[1]; |
||||||
320 | |||||||
321 | foreach ($row as $key => $val) { |
||||||
322 | // For nested data, send us back through this method... |
||||||
323 | if (is_array($val)) { |
||||||
324 | $pair = $this->parsePair($key, $val, $match[1]); |
||||||
325 | |||||||
326 | if ($pair !== []) { |
||||||
327 | $pairs[array_keys($pair)[0]] = true; |
||||||
328 | |||||||
329 | $temp = array_merge($temp, $pair); |
||||||
330 | } |
||||||
331 | |||||||
332 | continue; |
||||||
333 | } |
||||||
334 | |||||||
335 | if (is_object($val)) { |
||||||
336 | $val = 'Class: ' . $val::class; |
||||||
337 | } elseif (is_resource($val)) { |
||||||
338 | $val = 'Resource'; |
||||||
339 | } |
||||||
340 | |||||||
341 | $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' . $this->rightDelimiter . '#us'] = $val; |
||||||
342 | } |
||||||
343 | |||||||
344 | // Now replace our placeholders with the new content. |
||||||
345 | foreach ($temp as $pattern => $content) { |
||||||
346 | $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern])); |
||||||
347 | } |
||||||
348 | |||||||
349 | $str .= $out; |
||||||
350 | } |
||||||
351 | |||||||
352 | $escapedMatch = preg_quote($match[0], '#'); |
||||||
353 | |||||||
354 | $replace['#' . $escapedMatch . '#us'] = $str; |
||||||
355 | } |
||||||
356 | |||||||
357 | return $replace; |
||||||
358 | } |
||||||
359 | |||||||
360 | /** |
||||||
361 | * Removes any comments from the file. Comments are wrapped in {# #} symbols: |
||||||
362 | * |
||||||
363 | * {# This is a comment #} |
||||||
364 | */ |
||||||
365 | protected function parseComments(string $template): string |
||||||
366 | { |
||||||
367 | return preg_replace('/\{#.*?#\}/us', '', $template); |
||||||
368 | } |
||||||
369 | |||||||
370 | /** |
||||||
371 | * Extracts noparse blocks, inserting a hash in its place so that |
||||||
372 | * those blocks of the page are not touched by parsing. |
||||||
373 | */ |
||||||
374 | protected function extractNoparse(string $template): string |
||||||
375 | { |
||||||
376 | $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums'; |
||||||
377 | |||||||
378 | /* |
||||||
379 | * $matches[][0] is the raw match |
||||||
380 | * $matches[][1] is the contents |
||||||
381 | */ |
||||||
382 | if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) { |
||||||
383 | foreach ($matches as $match) { |
||||||
384 | // Create a hash of the contents to insert in its place. |
||||||
385 | $hash = md5($match[1]); |
||||||
386 | $this->noparseBlocks[$hash] = $match[1]; |
||||||
387 | $template = str_replace($match[0], "noparse_{$hash}", $template); |
||||||
388 | } |
||||||
389 | } |
||||||
390 | |||||||
391 | return $template; |
||||||
392 | } |
||||||
393 | |||||||
394 | /** |
||||||
395 | * Re-inserts the noparsed contents back into the template. |
||||||
396 | */ |
||||||
397 | public function insertNoparse(string $template): string |
||||||
398 | { |
||||||
399 | foreach ($this->noparseBlocks as $hash => $replace) { |
||||||
400 | $template = str_replace("noparse_{$hash}", $replace, $template); |
||||||
401 | unset($this->noparseBlocks[$hash]); |
||||||
402 | } |
||||||
403 | |||||||
404 | return $template; |
||||||
405 | } |
||||||
406 | |||||||
407 | /** |
||||||
408 | * Parses any conditionals in the code, removing blocks that don't |
||||||
409 | * pass so we don't try to parse it later. |
||||||
410 | * |
||||||
411 | * Valid conditionals: |
||||||
412 | * - if |
||||||
413 | * - elseif |
||||||
414 | * - else |
||||||
415 | */ |
||||||
416 | protected function parseConditionals(string $template): string |
||||||
417 | { |
||||||
418 | $leftDelimiter = preg_quote($this->leftConditionalDelimiter, '/'); |
||||||
419 | $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/'); |
||||||
420 | |||||||
421 | $pattern = '/' |
||||||
422 | . $leftDelimiter |
||||||
423 | . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*' |
||||||
424 | . $rightDelimiter |
||||||
425 | . '/ums'; |
||||||
426 | |||||||
427 | /* |
||||||
428 | * For each match: |
||||||
429 | * [0] = raw match `{if var}` |
||||||
430 | * [1] = conditional `if` |
||||||
431 | * [2] = condition `do === true` |
||||||
432 | * [3] = same as [2] |
||||||
433 | */ |
||||||
434 | preg_match_all($pattern, $template, $matches, PREG_SET_ORDER); |
||||||
435 | |||||||
436 | foreach ($matches as $match) { |
||||||
437 | // Build the string to replace the `if` statement with. |
||||||
438 | $condition = $match[2]; |
||||||
439 | |||||||
440 | $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>'; |
||||||
441 | $template = str_replace($match[0], $statement, $template); |
||||||
442 | } |
||||||
443 | |||||||
444 | $template = preg_replace( |
||||||
445 | '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums', |
||||||
446 | '<?php else: ?>', |
||||||
447 | $template |
||||||
448 | ); |
||||||
449 | $template = preg_replace( |
||||||
450 | '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums', |
||||||
451 | '<?php endif; ?>', |
||||||
452 | $template |
||||||
453 | ); |
||||||
454 | |||||||
455 | // Parse the PHP itself, or insert an error so they can debug |
||||||
456 | ob_start(); |
||||||
457 | |||||||
458 | if ($this->tempData === null) { |
||||||
459 | $this->tempData = $this->data; |
||||||
460 | } |
||||||
461 | |||||||
462 | extract($this->tempData); |
||||||
463 | |||||||
464 | try { |
||||||
465 | eval('?>' . $template . '<?php '); |
||||||
0 ignored issues
–
show
|
|||||||
466 | } catch (ParseError) { |
||||||
0 ignored issues
–
show
catch (\ParseError) is not reachable.
This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed. Unreachable code is most often the result of function fx() {
try {
doSomething();
return true;
}
catch (\Exception $e) {
return false;
}
return false;
}
In the above example, the last ![]() |
|||||||
467 | ob_end_clean(); |
||||||
468 | |||||||
469 | throw ViewException::tagSyntaxError(str_replace(['?>', '<?php '], '', $template)); |
||||||
470 | } |
||||||
471 | |||||||
472 | return ob_get_clean(); |
||||||
473 | } |
||||||
474 | |||||||
475 | /** |
||||||
476 | * Over-ride the substitution field delimiters. |
||||||
477 | */ |
||||||
478 | public function setDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static |
||||||
479 | { |
||||||
480 | $this->leftDelimiter = $leftDelimiter; |
||||||
481 | $this->rightDelimiter = $rightDelimiter; |
||||||
482 | |||||||
483 | return $this; |
||||||
484 | } |
||||||
485 | |||||||
486 | /** |
||||||
487 | * Over-ride the substitution conditional delimiters. |
||||||
488 | */ |
||||||
489 | public function setConditionalDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static |
||||||
490 | { |
||||||
491 | $this->leftConditionalDelimiter = $leftDelimiter; |
||||||
492 | $this->rightConditionalDelimiter = $rightDelimiter; |
||||||
493 | |||||||
494 | return $this; |
||||||
495 | } |
||||||
496 | |||||||
497 | /** |
||||||
498 | * Handles replacing a pseudo-variable with the actual content. Will double-check |
||||||
499 | * for escaping brackets. |
||||||
500 | */ |
||||||
501 | protected function replaceSingle(array|string $pattern, string $content, string $template, bool $escape = false): string |
||||||
502 | { |
||||||
503 | // Replace the content in the template |
||||||
504 | return preg_replace_callback($pattern, function ($matches) use ($content, $escape): string { |
||||||
505 | // Check for {! !} syntax to not escape this one. |
||||||
506 | if ( |
||||||
507 | str_starts_with($matches[0], $this->leftDelimiter . '!') |
||||||
508 | && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter |
||||||
509 | ) { |
||||||
510 | $escape = false; |
||||||
511 | } |
||||||
512 | |||||||
513 | return $this->prepareReplacement($matches, $content, $escape); |
||||||
514 | }, $template); |
||||||
515 | } |
||||||
516 | |||||||
517 | /** |
||||||
518 | * Callback used during parse() to apply any filters to the value. |
||||||
519 | */ |
||||||
520 | protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string |
||||||
521 | { |
||||||
522 | $orig = array_shift($matches); |
||||||
523 | |||||||
524 | // Our regex earlier will leave all chained values on a single line |
||||||
525 | // so we need to break them apart so we can apply them all. |
||||||
526 | $filters = ! empty($matches[1]) ? explode('|', $matches[1]) : []; |
||||||
527 | |||||||
528 | if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) { |
||||||
529 | $filters[] = "esc({$context})"; |
||||||
530 | } |
||||||
531 | |||||||
532 | return $this->applyFilters($replace, $filters); |
||||||
533 | } |
||||||
534 | |||||||
535 | /** |
||||||
536 | * Checks the placeholder the view provided to see if we need to provide any autoescaping. |
||||||
537 | * |
||||||
538 | * @return false|string |
||||||
539 | */ |
||||||
540 | public function shouldAddEscaping(string $key) |
||||||
541 | { |
||||||
542 | $escape = false; |
||||||
543 | |||||||
544 | $key = trim(str_replace(['{', '}'], '', $key)); |
||||||
545 | |||||||
546 | // If the key has a context stored (from setData) |
||||||
547 | // we need to respect that. |
||||||
548 | if (array_key_exists($key, $this->dataContexts)) { |
||||||
549 | if ($this->dataContexts[$key] !== 'raw') { |
||||||
550 | return $this->dataContexts[$key]; |
||||||
551 | } |
||||||
552 | } |
||||||
553 | // No pipes, then we know we need to escape |
||||||
554 | elseif (! str_contains($key, '|')) { |
||||||
555 | $escape = 'html'; |
||||||
556 | } |
||||||
557 | // If there's a `noescape` then we're definitely false. |
||||||
558 | elseif (str_contains($key, 'noescape')) { |
||||||
559 | $escape = false; |
||||||
560 | } |
||||||
561 | // If no `esc` filter is found, then we'll need to add one. |
||||||
562 | elseif (! preg_match('/\s+esc/u', $key)) { |
||||||
563 | $escape = 'html'; |
||||||
564 | } |
||||||
565 | |||||||
566 | return $escape; |
||||||
567 | } |
||||||
568 | |||||||
569 | /** |
||||||
570 | * Given a set of filters, will apply each of the filters in turn |
||||||
571 | * to $replace, and return the modified string. |
||||||
572 | */ |
||||||
573 | protected function applyFilters(string $replace, array $filters): string |
||||||
574 | { |
||||||
575 | // Determine the requested filters |
||||||
576 | foreach ($filters as $filter) { |
||||||
577 | // Grab any parameter we might need to send |
||||||
578 | preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param); |
||||||
579 | |||||||
580 | // Remove the () and spaces to we have just the parameter left |
||||||
581 | $param = $param !== [] ? trim($param[0], '() ') : null; |
||||||
582 | |||||||
583 | // Params can be separated by commas to allow multiple parameters for the filter |
||||||
584 | if ($param !== null && $param !== '' && $param !== '0') { |
||||||
585 | $param = explode(',', $param); |
||||||
586 | |||||||
587 | // Clean it up |
||||||
588 | foreach ($param as &$p) { |
||||||
589 | $p = trim($p, ' "'); |
||||||
590 | } |
||||||
591 | } else { |
||||||
592 | $param = []; |
||||||
593 | } |
||||||
594 | |||||||
595 | // Get our filter name |
||||||
596 | $filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter); |
||||||
597 | |||||||
598 | $this->config['filters'] ??= []; |
||||||
599 | if (! array_key_exists($filter, $this->config['filters'])) { |
||||||
600 | continue; |
||||||
601 | } |
||||||
602 | |||||||
603 | // Filter it.... |
||||||
604 | $replace = $this->config['filters'][$filter]($replace, ...$param); |
||||||
605 | } |
||||||
606 | |||||||
607 | return (string) $replace; |
||||||
608 | } |
||||||
609 | |||||||
610 | // Plugins |
||||||
611 | |||||||
612 | /** |
||||||
613 | * Scans the template for any parser plugins, and attempts to execute them. |
||||||
614 | * Plugins are delimited by {+ ... +} |
||||||
615 | */ |
||||||
616 | protected function parsePlugins(string $template): string |
||||||
617 | { |
||||||
618 | foreach ($this->plugins as $plugin => $callable) { |
||||||
619 | // Paired tags are enclosed in an array in the config array. |
||||||
620 | $isPair = is_array($callable); |
||||||
621 | $callable = $isPair ? array_shift($callable) : $callable; |
||||||
622 | |||||||
623 | // See https://regex101.com/r/BCBBKB/1 |
||||||
624 | $pattern = $isPair |
||||||
625 | ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims' |
||||||
626 | : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims'; |
||||||
627 | |||||||
628 | /** |
||||||
629 | * Match tag pairs |
||||||
630 | * |
||||||
631 | * Each match is an array: |
||||||
632 | * $matches[0] = entire matched string |
||||||
633 | * $matches[1] = all parameters string in opening tag |
||||||
634 | * $matches[2] = content between the tags to send to the plugin. |
||||||
635 | */ |
||||||
636 | if (! preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) { |
||||||
637 | continue; |
||||||
638 | } |
||||||
639 | |||||||
640 | foreach ($matches as $match) { |
||||||
641 | $params = []; |
||||||
642 | |||||||
643 | preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams); |
||||||
644 | |||||||
645 | foreach ($matchesParams[0] as $item) { |
||||||
646 | $keyVal = explode('=', $item); |
||||||
647 | |||||||
648 | if (count($keyVal) === 2) { |
||||||
649 | $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]); |
||||||
650 | } else { |
||||||
651 | $params[] = str_replace('"', '', $item); |
||||||
652 | } |
||||||
653 | } |
||||||
654 | |||||||
655 | $template = $isPair |
||||||
656 | ? str_replace($match[0], $callable($match[2], $params), $template) |
||||||
657 | : str_replace($match[0], $callable($params), $template); |
||||||
658 | } |
||||||
659 | } |
||||||
660 | |||||||
661 | return $template; |
||||||
662 | } |
||||||
663 | |||||||
664 | /** |
||||||
665 | * Makes a new plugin available during the parsing of the template. |
||||||
666 | */ |
||||||
667 | public function addPlugin(string $alias, callable $callback, bool $isPair = false): self |
||||||
668 | { |
||||||
669 | $this->plugins[$alias] = $isPair ? [$callback] : $callback; |
||||||
670 | |||||||
671 | return $this; |
||||||
672 | } |
||||||
673 | |||||||
674 | /** |
||||||
675 | * Removes a plugin from the available plugins. |
||||||
676 | */ |
||||||
677 | public function removePlugin(string $alias): self |
||||||
678 | { |
||||||
679 | unset($this->plugins[$alias]); |
||||||
680 | |||||||
681 | return $this; |
||||||
682 | } |
||||||
683 | |||||||
684 | /** |
||||||
685 | * Converts an object to an array, respecting any |
||||||
686 | * toArray() methods on an object. |
||||||
687 | * |
||||||
688 | * @param array|bool|float|int|object|string|null $value |
||||||
689 | * |
||||||
690 | * @return array|bool|float|int|string|null |
||||||
691 | */ |
||||||
692 | protected function objectToArray($value) |
||||||
693 | { |
||||||
694 | // Objects that have a `toArray()` method should be |
||||||
695 | // converted with that method (i.e. Entities) |
||||||
696 | if (is_object($value) && method_exists($value, 'toArray')) { |
||||||
697 | $value = $value->toArray(); |
||||||
698 | } |
||||||
699 | // Otherwise, cast as an array and it will grab public properties. |
||||||
700 | elseif (is_object($value)) { |
||||||
701 | $value = (array) $value; |
||||||
702 | } |
||||||
703 | |||||||
704 | return $value; |
||||||
705 | } |
||||||
706 | } |
||||||
707 |