xref: /web-bugs/src/Template/Engine.php (revision 068d8514)
1<?php
2
3namespace App\Template;
4
5/**
6 * A simple template engine that assigns global variables to the templates and
7 * renders given template.
8 */
9class Engine
10{
11    /**
12     * Templates directory contains all application templates.
13     *
14     * @var string
15     */
16    private $dir;
17
18    /**
19     * Registered callables.
20     *
21     * @var array
22     */
23    private $callables = [];
24
25    /**
26     * Assigned variables after template initialization and before calling the
27     * render method.
28     *
29     * @var array
30     */
31    private $variables = [];
32
33    /**
34     * Template context.
35     *
36     * @var Context
37     */
38    private $context;
39
40    /**
41     * Class constructor.
42     */
43    public function __construct(string $dir)
44    {
45        if (!is_dir($dir)) {
46            throw new \Exception($dir.' is missing or not a valid directory.');
47        }
48
49        $this->dir = $dir;
50    }
51
52    /**
53     * This enables assigning new variables to the template scope right after
54     * initializing a template engine. Some variables in templates are like
55     * parameters or globals and should be added only on one place instead of
56     * repeating them at each ...->render() call.
57     */
58    public function assign(array $variables = []): void
59    {
60        $this->variables = array_replace($this->variables, $variables);
61    }
62
63    /**
64     * Get assigned variables of the template.
65     */
66    public function getVariables(): array
67    {
68        return $this->variables;
69    }
70
71    /**
72     * Add new template helper function as a callable defined in the (front)
73     * controller to the template scope.
74     */
75    public function register(string $name, callable $callable): void
76    {
77        if (method_exists(Context::class, $name)) {
78            throw new \Exception(
79                $name.' is already registered by the template engine. Use a different name.'
80            );
81        }
82
83        $this->callables[$name] = $callable;
84    }
85
86    /**
87     * Renders given template file and populates its scope with variables
88     * provided as array elements. Each array key is a variable name in template
89     * scope and array item value is set as a variable value.
90     */
91    public function render(string $template, array $variables = []): string
92    {
93        $variables = array_replace($this->variables, $variables);
94
95        $this->context = new Context(
96            $this->dir,
97            $variables,
98            $this->callables
99        );
100
101        $buffer = $this->bufferize($template, $variables);
102
103        while (!empty($current = array_shift($this->context->tree))) {
104            $buffer = trim($buffer);
105            $buffer .= $this->bufferize($current[0], $current[1]);
106        }
107
108        return $buffer;
109    }
110
111    /**
112     * Processes given template file, merges variables into template scope using
113     * output buffering and returns the rendered content string. Note that $this
114     * pseudo-variable in the closure refers to the scope of the Context class.
115     */
116    private function bufferize(string $template, array $variables = []): string
117    {
118        if (!is_file($this->dir.'/'.$template)) {
119            throw new \Exception($template.' is missing or not a valid template.');
120        }
121
122        $closure = \Closure::bind(
123            function ($template, $variables) {
124                $this->current = $template;
125                $this->variables = array_replace($this->variables, $variables);
126                unset($variables, $template);
127
128                if (count($this->variables) > extract($this->variables, EXTR_SKIP)) {
129                    throw new \Exception(
130                        'Variables with numeric names $0, $1... cannot be imported to scope '.$this->current
131                    );
132                }
133
134                ++$this->bufferLevel;
135
136                ob_start();
137
138                try {
139                    include $this->dir.'/'.$this->current;
140                } catch (\Exception $e) {
141                    // Close all opened buffers
142                    while ($this->bufferLevel > 0) {
143                        --$this->bufferLevel;
144
145                        ob_end_clean();
146                    }
147
148                    throw $e;
149                }
150
151                --$this->bufferLevel;
152
153                return ob_get_clean();
154            },
155            $this->context,
156            Context::class
157        );
158
159        return $closure($template, $variables);
160    }
161}
162