1<?php
2/**
3 * Helpers
4 *
5 * a collection of helper function. normally a function like
6 * function ($sender, $name, $arguments) $arguments is unscaped arguments and
7 * is a string, not array
8 *
9 * @category  Xamin
10 * @package   Handlebars
11 * @author    fzerorubigd <fzerorubigd@gmail.com>
12 * @author    Behrooz Shabani <everplays@gmail.com>
13 * @author    Mardix <https://github.com/mardix>
14 * @copyright 2012 (c) ParsPooyesh Co
15 * @copyright 2013 (c) Behrooz Shabani
16 * @copyright 2014 (c) Mardix
17 * @license   MIT
18 * @link      http://voodoophp.org/docs/handlebars
19 */
20
21namespace Handlebars;
22
23use DateTime;
24use InvalidArgumentException;
25use Traversable;
26use LogicException;
27
28class Helpers
29{
30    /**
31     * @var array array of helpers
32     */
33    protected $helpers = [];
34    private $tpl = [];
35    protected $builtinHelpers = [
36        "if",
37        "each",
38        "with",
39        "unless",
40        "bindAttr",
41        "upper",                // Put all chars in uppercase
42        "lower",                // Put all chars in lowercase
43        "capitalize",           // Capitalize just the first word
44        "capitalize_words",     // Capitalize each words
45        "reverse",              // Reverse a string
46        "format_date",          // Format a date
47        "inflect",              // Inflect the wording based on count ie. 1 album, 10 albums
48        "default",              // If a variable is null, it will use the default instead
49        "truncate",             // Truncate section
50        "raw",                  // Return the source as is without converting
51        "repeat",               // Repeat a section
52        "define",               // Define a block to be used using "invoke"
53        "invoke",               // Invoke a block that was defined with "define"
54    ];
55
56    /**
57     * Create new helper container class
58     *
59     * @param array      $helpers  array of name=>$value helpers
60     * @throws \InvalidArgumentException when $helpers is not an array
61     * (or traversable) or helper is not a callable
62     */
63    public function __construct($helpers = null)
64    {
65        foreach($this->builtinHelpers as $helper) {
66            $helperName = $this->underscoreToCamelCase($helper);
67            $this->add($helper, [$this, "helper{$helperName}"]);
68        }
69
70        if ($helpers != null) {
71            if (!is_array($helpers) && !$helpers instanceof Traversable) {
72                throw new InvalidArgumentException(
73                    'HelperCollection constructor expects an array of helpers'
74                );
75            }
76            foreach ($helpers as $name => $helper) {
77                $this->add($name, $helper);
78            }
79        }
80    }
81
82    /**
83     * Add a new helper to helpers
84     *
85     * @param string   $name   helper name
86     * @param callable $helper a function as a helper
87     *
88     * @throws \InvalidArgumentException if $helper is not a callable
89     * @return void
90     */
91    public function add($name, $helper)
92    {
93        if (!is_callable($helper)) {
94            throw new InvalidArgumentException("$name Helper is not a callable.");
95        }
96        $this->helpers[$name] = $helper;
97    }
98
99    /**
100     * Check if $name helper is available
101     *
102     * @param string $name helper name
103     *
104     * @return boolean
105     */
106    public function has($name)
107    {
108        return array_key_exists($name, $this->helpers);
109    }
110
111    /**
112     * Get a helper. __magic__ method :)
113     *
114     * @param string $name helper name
115     *
116     * @throws \InvalidArgumentException if $name is not available
117     * @return callable helper function
118     */
119    public function __get($name)
120    {
121        if (!$this->has($name)) {
122            throw new InvalidArgumentException('Unknown helper :' . $name);
123        }
124        return $this->helpers[$name];
125    }
126
127    /**
128     * Check if $name helper is available __magic__ method :)
129     *
130     * @param string $name helper name
131     *
132     * @return boolean
133     * @see Handlebras_Helpers::has
134     */
135    public function __isset($name)
136    {
137        return $this->has($name);
138    }
139
140    /**
141     * Add a new helper to helpers __magic__ method :)
142     *
143     * @param string   $name   helper name
144     * @param callable $helper a function as a helper
145     *
146     * @return void
147     */
148    public function __set($name, $helper)
149    {
150        $this->add($name, $helper);
151    }
152
153
154    /**
155     * Unset a helper
156     *
157     * @param string $name helper name to remove
158     * @return void
159     */
160    public function __unset($name)
161    {
162        unset($this->helpers[$name]);
163    }
164
165    /**
166     * Check whether a given helper is present in the collection.
167     *
168     * @param string $name helper name
169     * @throws \InvalidArgumentException if the requested helper is not present.
170     * @return void
171     */
172    public function remove($name)
173    {
174        if (!$this->has($name)) {
175            throw new InvalidArgumentException('Unknown helper: ' . $name);
176        }
177        unset($this->helpers[$name]);
178    }
179
180    /**
181     * Clear the helper collection.
182     *
183     * Removes all helpers from this collection
184     *
185     * @return void
186     */
187    public function clear()
188    {
189        $this->helpers = [];
190    }
191
192    /**
193     * Check whether the helper collection is empty.
194     *
195     * @return boolean True if the collection is empty
196     */
197    public function isEmpty()
198    {
199        return empty($this->helpers);
200    }
201
202    /**
203     * Create handler for the 'if' helper.
204     *
205     * {{#if condition}}
206     *      Something here
207     * {{else if condition}}
208     *      something else if here
209     * {{else if condition}}
210     *      something else if here
211     * {{else}}
212     *      something else here
213     * {{/if}}
214     *
215     * @param \Handlebars\Template $template template that is being rendered
216     * @param \Handlebars\Context  $context  context object
217     * @param array                $args     passed arguments to helper
218     * @param string               $source   part of template that is wrapped
219     *                                       within helper
220     *
221     * @return mixed
222     */
223    public function helperIf($template, $context, $args, $source)
224    {
225        $tpl = $template->getEngine()->loadString('{{#if ' . $args . '}}' . $source . '{{/if}}');
226        $tree = $tpl->getTree();
227        $tmp = $context->get($args);
228        if ($tmp) {
229            $token = 'else';
230            foreach ($tree[0]['nodes'] as $node) {
231                $name = trim($node['name'] ?? '');
232                if ($name && substr($name, 0, 7) == 'else if') {
233                    $token = $node['name'];
234                    break;
235                }
236            }
237            $template->setStopToken($token);
238            $buffer = $template->render($context);
239            $template->setStopToken(false);
240            $template->discard();
241            return $buffer;
242        } else {
243            foreach ($tree[0]['nodes'] as $key => $node) {
244                $name = trim(isset($node['name']) ? $node['name'] : '');
245                if ($name && substr($name, 0, 7) == 'else if') {
246                    $template->setStopToken($node['name']);
247                    $template->discard();
248                    $template->setStopToken(false);
249                    $args = $this->parseArgs($context, substr($name, 7));
250                    $token = 'else';
251                    $remains = array_slice($tree[0]['nodes'], $key + 1);
252                    foreach ($remains as $remain) {
253                        $name = trim($remain['name'] ?? '');
254                        if ($name && substr($name, 0, 7) == 'else if') {
255                            $token = $remain['name'];
256                            break;
257                        }
258                    }
259                    if (isset($args[0]) && $args[0]) {
260                        $template->setStopToken($token);
261                        $buffer = $template->render($context);
262                        $template->setStopToken(false);
263                        $template->discard();
264                        return $buffer;
265                    } else if ($token != 'else') {
266                        continue;
267                    } else {
268                        return $this->renderElse($template, $context);
269                    }
270                }
271            }
272            return $this->renderElse($template, $context);
273        }
274    }
275
276
277    /**
278     * Create handler for the 'each' helper.
279     * example {{#each people}} {{name}} {{/each}}
280     * example with slice: {{#each people[0:10]}} {{name}} {{/each}}
281     * example with else
282     *  {{#each Array}}
283     *        {{.}}
284     *  {{else}}
285     *      Nothing found
286     *  {{/each}}
287     *
288     * @param \Handlebars\Template $template template that is being rendered
289     * @param \Handlebars\Context  $context  context object
290     * @param array                $args     passed arguments to helper
291     * @param string               $source   part of template that is wrapped
292     *                                       within helper
293     *
294     * @return mixed
295     */
296    public function helperEach($template, $context, $args, $source)
297    {
298        list($keyname, $slice_start, $slice_end) = $this->extractSlice($args);
299        $tmp = $context->get($keyname);
300
301        if (is_array($tmp) || $tmp instanceof Traversable) {
302            $tmp = array_slice($tmp, $slice_start ?? 0, $slice_end, true);
303            $buffer = '';
304            $islist = array_values($tmp) === $tmp;
305
306            if (is_array($tmp) && ! count($tmp)) {
307                return $this->renderElse($template, $context);
308            } else {
309
310                $itemCount = -1;
311                if ($islist) {
312                    $itemCount = count($tmp);
313                }
314
315                foreach ($tmp as $key => $var) {
316                    $tpl = clone $template;
317                    if ($islist) {
318                        $context->pushIndex($key);
319
320                        // If data variables are enabled, push the data related to this #each context
321                        if ($template->getEngine()->isDataVariablesEnabled()) {
322                            $context->pushData([
323                                Context::DATA_KEY => $key,
324                                Context::DATA_INDEX => $key,
325                                Context::DATA_LAST => $key == ($itemCount - 1),
326                                Context::DATA_FIRST => $key == 0,
327                            ]);
328                        }
329                    } else {
330                        $context->pushKey($key);
331
332                        // If data variables are enabled, push the data related to this #each context
333                        if ($template->getEngine()->isDataVariablesEnabled()) {
334                            $context->pushData([
335                                Context::DATA_KEY => $key,
336                            ]);
337                        }
338                    }
339                    $context->push($var);
340                    $tpl->setStopToken('else');
341                    $buffer .= $tpl->render($context);
342                    $context->pop();
343                    if ($islist) {
344                        $context->popIndex();
345                    } else {
346                        $context->popKey();
347                    }
348
349                    if ($template->getEngine()->isDataVariablesEnabled()) {
350                        $context->popData();
351                    }
352                }
353                return $buffer;
354            }
355        } else {
356            return $this->renderElse($template, $context);
357        }
358    }
359
360    /**
361     * Applying the DRY principle here.
362     * This method help us render {{else}} portion of a block
363     * @param \Handlebars\Template $template
364     * @param \Handlebars\Context $context
365     * @return string
366     */
367    private function renderElse($template, $context)
368    {
369        $template->setStopToken('else');
370        $template->discard();
371        $template->setStopToken(false);
372        return $template->render($context);
373    }
374
375
376    /**
377     * Create handler for the 'unless' helper.
378     * {{#unless condition}}
379     *      Something here
380     * {{else}}
381     *      something else here
382     * {{/unless}}
383     * @param \Handlebars\Template $template template that is being rendered
384     * @param \Handlebars\Context  $context  context object
385     * @param array                $args     passed arguments to helper
386     * @param string               $source   part of template that is wrapped
387     *                                       within helper
388     *
389     * @return mixed
390     */
391    public function helperUnless($template, $context, $args, $source)
392    {
393        $tmp = $context->get($args);
394        if (!$tmp) {
395            $template->setStopToken('else');
396            $buffer = $template->render($context);
397            $template->setStopToken(false);
398            $template->discard();
399            return $buffer;
400        } else {
401            return $this->renderElse($template, $context);
402        }
403    }
404
405    /**
406     * Create handler for the 'with' helper.
407     * Needed for compatibility with PHP 5.2 since it doesn't support anonymous
408     * functions.
409     *
410     * @param \Handlebars\Template $template template that is being rendered
411     * @param \Handlebars\Context  $context  context object
412     * @param array                $args     passed arguments to helper
413     * @param string               $source   part of template that is wrapped
414     *                                       within helper
415     *
416     * @return mixed
417     */
418    public function helperWith($template, $context, $args, $source)
419    {
420        $tmp = $context->get($args);
421        $context->push($tmp);
422        $buffer = $template->render($context);
423        $context->pop();
424
425        return $buffer;
426    }
427
428    /**
429     * Create handler for the 'bindAttr' helper.
430     * Needed for compatibility with PHP 5.2 since it doesn't support anonymous
431     * functions.
432     *
433     * @param \Handlebars\Template $template template that is being rendered
434     * @param \Handlebars\Context  $context  context object
435     * @param array                $args     passed arguments to helper
436     * @param string               $source   part of template that is wrapped
437     *                                       within helper
438     *
439     * @return mixed
440     */
441    public function helperBindAttr($template, $context, $args, $source)
442    {
443        return $args;
444    }
445
446    /**
447     * To uppercase string
448     *
449     * {{#upper data}}
450     *
451     * @param \Handlebars\Template $template template that is being rendered
452     * @param \Handlebars\Context  $context  context object
453     * @param array                $args     passed arguments to helper
454     * @param string               $source   part of template that is wrapped
455     *                                       within helper
456     *
457     * @return string
458     */
459    public function helperUpper($template, $context, $args, $source)
460    {
461        return strtoupper($context->get($args));
462    }
463
464    /**
465     * To lowercase string
466     *
467     * {{#lower data}}
468     *
469     * @param \Handlebars\Template $template template that is being rendered
470     * @param \Handlebars\Context  $context  context object
471     * @param array                $args     passed arguments to helper
472     * @param string               $source   part of template that is wrapped
473     *                                       within helper
474     *
475     * @return string
476     */
477    public function helperLower($template, $context, $args, $source)
478    {
479        return strtolower($context->get($args));
480    }
481
482    /**
483     * to capitalize first letter
484     *
485     * {{#capitalize}}
486     *
487     * @param \Handlebars\Template $template template that is being rendered
488     * @param \Handlebars\Context  $context  context object
489     * @param array                $args     passed arguments to helper
490     * @param string               $source   part of template that is wrapped
491     *                                       within helper
492     *
493     * @return string
494     */
495    public function helperCapitalize($template, $context, $args, $source)
496    {
497        return ucfirst($context->get($args));
498    }
499
500    /**
501     * To capitalize first letter in each word
502     *
503     * {{#capitalize_words data}}
504     *
505     * @param \Handlebars\Template $template template that is being rendered
506     * @param \Handlebars\Context  $context  context object
507     * @param array                $args     passed arguments to helper
508     * @param string               $source   part of template that is wrapped
509     *                                       within helper
510     *
511     * @return string
512     */
513    public function helperCapitalizeWords($template, $context, $args, $source)
514    {
515        return ucwords($context->get($args));
516    }
517
518    /**
519     * To reverse a string
520     *
521     * {{#reverse data}}
522     *
523     * @param \Handlebars\Template $template template that is being rendered
524     * @param \Handlebars\Context  $context  context object
525     * @param array                $args     passed arguments to helper
526     * @param string               $source   part of template that is wrapped
527     *                                       within helper
528     *
529     * @return string
530     */
531    public function helperReverse($template, $context, $args, $source)
532    {
533        return strrev($context->get($args));
534    }
535
536    /**
537     * Format a date
538     *
539     * {{#format_date date 'Y-m-d @h:i:s'}}
540     *
541     * @param \Handlebars\Template $template template that is being rendered
542     * @param \Handlebars\Context  $context  context object
543     * @param array                $args     passed arguments to helper
544     * @param string               $source   part of template that is wrapped
545     *                                       within helper
546     *
547     * @return mixed
548     */
549    public function helperFormatDate($template, $context, $args, $source)
550    {
551        preg_match("/(.*?)\s+(?:(?:\"|\')(.*?)(?:\"|\'))/", $args, $m);
552        $keyname = $m[1];
553        $format = $m[2];
554
555        $date = $context->get($keyname);
556        if ($format) {
557            $dt = new DateTime;
558            if (is_numeric($date)) {
559                $dt = (new DateTime)->setTimestamp($date);
560            } else {
561                $dt = new DateTime($date);
562            }
563            return $dt->format($format);
564        } else {
565            return $date;
566        }
567    }
568
569    /**
570     * {{inflect count 'album' 'albums'}}
571     * {{inflect count '%d album' '%d albums'}}
572     *
573     * @param \Handlebars\Template $template template that is being rendered
574     * @param \Handlebars\Context  $context  context object
575     * @param array                $args     passed arguments to helper
576     * @param string               $source   part of template that is wrapped
577     *                                       within helper
578     *
579     * @return mixed
580     */
581    public function helperInflect($template, $context, $args, $source)
582    {
583        preg_match("/(.*?)\s+(?:(?:\"|\')(.*?)(?:\"|\'))\s+(?:(?:\"|\')(.*?)(?:\"|\'))/", $args, $m);
584        $keyname = $m[1];
585        $singular = $m[2];
586        $plurial = $m[3];
587        $value = $context->get($keyname);
588        $inflect = ($value <= 1) ? $singular : $plurial;
589        return sprintf($inflect, $value);
590    }
591
592   /**
593     * Provide a default fallback
594    *
595     * {{default title "No title available"}}
596     *
597     * @param \Handlebars\Template $template template that is being rendered
598     * @param \Handlebars\Context  $context  context object
599     * @param array                $args     passed arguments to helper
600     * @param string               $source   part of template that is wrapped
601     *                                       within helper
602     *
603     * @return string
604     */
605    public function helperDefault($template, $context, $args, $source)
606    {
607        preg_match("/(.*?)\s+(?:(?:\"|\')(.*?)(?:\"|\'))/", trim($args), $m);
608        $keyname = $m[1];
609        $default = $m[2];
610        $value = $context->get($keyname);
611        return ($value) ?: $default;
612    }
613
614   /**
615     * Truncate a string to a length, and append and ellipsis if provided
616     * {{#truncate content 5 "..."}}
617     *
618     *
619     * @param \Handlebars\Template $template template that is being rendered
620     * @param \Handlebars\Context  $context  context object
621     * @param array                $args     passed arguments to helper
622     * @param string               $source   part of template that is wrapped
623     *                                       within helper
624     *
625     * @return string
626     */
627    public function helperTruncate($template, $context, $args, $source)
628    {
629        preg_match("/(.*?)\s+(.*?)\s+(?:(?:\"|\')(.*?)(?:\"|\'))/", trim($args), $m);
630        $keyname = $m[1];
631        $limit = $m[2];
632        $ellipsis = $m[3];
633        $value = substr($context->get($keyname), 0, $limit);
634        if ($ellipsis && strlen($context->get($keyname)) > $limit) {
635            $value .= $ellipsis;
636        }
637        return $value;
638    }
639
640    /**
641     * Return the data source as is
642     *
643     * {{#raw}} {{/raw}}
644     *
645     * @param \Handlebars\Template $template template that is being rendered
646     * @param \Handlebars\Context  $context  context object
647     * @param array                $args     passed arguments to helper
648     * @param string               $source   part of template that is wrapped
649     *                                       within helper
650     *
651     * @return mixed
652     */
653    public function helperRaw($template, $context, $args, $source)
654    {
655        return $source;
656    }
657
658    /**
659     * Repeat section $x times.
660     *
661     * {{#repeat 10}}
662     *      This section will be repeated 10 times
663     * {{/repeat}}
664     *
665     *
666     * @param \Handlebars\Template $template template that is being rendered
667     * @param \Handlebars\Context  $context  context object
668     * @param array                $args     passed arguments to helper
669     * @param string               $source   part of template that is wrapped
670     *                                       within helper
671     *
672     * @return string
673     */
674    public function helperRepeat($template, $context, $args, $source)
675    {
676        $buffer = $template->render($context);
677        return str_repeat($buffer, intval($args));
678    }
679
680
681    /**
682     * Define a section to be used later by using 'invoke'
683     *
684     * --> Define a section: hello
685     * {{#define hello}}
686     *      Hello World!
687     *
688     *      How is everything?
689     * {{/define}}
690     *
691     * --> This is how it is called
692     * {{#invoke hello}}
693     *
694     *
695     * @param \Handlebars\Template $template template that is being rendered
696     * @param \Handlebars\Context  $context  context object
697     * @param array                $args     passed arguments to helper
698     * @param string               $source   part of template that is wrapped
699     *                                       within helper
700     *
701     * @return null
702     */
703    public function helperDefine($template, $context, $args, $source)
704    {
705        $this->tpl["DEFINE"][$args] = clone($template);
706    }
707
708    /**
709     * Invoke a section that was created using 'define'
710     *
711     * --> Define a section: hello
712     * {{#define hello}}
713     *      Hello World!
714     *
715     *      How is everything?
716     * {{/define}}
717     *
718     * --> This is how it is called
719     * {{#invoke hello}}
720     *
721     *
722     * @param \Handlebars\Template $template template that is being rendered
723     * @param \Handlebars\Context  $context  context object
724     * @param array                $args     passed arguments to helper
725     * @param string               $source   part of template that is wrapped
726     *                                       within helper
727     *
728     * @return null
729     */
730    public function helperInvoke($template, $context, $args, $source)
731    {
732        if (! isset($this->tpl["DEFINE"][$args])) {
733            throw new LogicException("Can't INVOKE '{$args}'. '{$args}' was not DEFINE ");
734        }
735        return $this->tpl["DEFINE"][$args]->render($context);
736    }
737
738
739    /**
740     * Change underscore helper name to CamelCase
741     *
742     * @param string $string
743     * @return string
744     */
745    private function underscoreToCamelCase($string)
746    {
747        return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
748    }
749
750    /**
751     * slice
752     * Allow to split the data that will be returned
753     * #loop[start:end] => starts at start trhough end -1
754     * #loop[start:] = Starts at start though the rest of the array
755     * #loop[:end] = Starts at the beginning through end -1
756     * #loop[:] = A copy of the whole array
757     *
758     * #loop[-1]
759     * #loop[-2:] = Last two items
760     * #loop[:-2] = Everything except last two items
761     *
762     * @param string $string
763     * @return Array [tag_name, slice_start, slice_end]
764     */
765    private function extractSlice($string)
766    {
767        preg_match("/^([\w\._\-]+)(?:\[([\-0-9]*?:[\-0-9]*?)\])?/i", $string, $m);
768        $slice_start = $slice_end = null;
769        if (isset($m[2])) {
770            list($slice_start, $slice_end) = explode(":", $m[2]);
771            $slice_start = (int) $slice_start;
772            $slice_end = $slice_end ? (int) $slice_end : null;
773        }
774        return [$m[1], $slice_start, $slice_end];
775    }
776
777    /**
778     * Parse avariable from current args
779     *
780     * @param \Handlebars\Context  $context  context object
781     * @param array                $args     passed arguments to helper
782     * @return array
783     */
784    private function parseArgs($context, $args)
785    {
786        $args = preg_replace('/\s+/', ' ', trim($args));
787        $eles = explode(' ', $args);
788        foreach ($eles as $key => $ele) {
789            if (in_array(substr($ele, 0, 1), ['\'', '"'])) {
790                $val = trim($ele, '\'"');
791            } else if (is_numeric($ele)) {
792                $val = $ele;
793            } else {
794                $val = $context->get($ele);
795            }
796            $eles[$key] = $val;
797        }
798        return $eles;
799    }
800}
801