Issues (1)

src/Renderer.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Devanych\View;
6
7
use Devanych\View\Extension\ExtensionInterface;
8
use Closure;
9
use RuntimeException;
10
use Throwable;
11
12
use function array_key_exists;
13
use function extract;
14
use function func_get_arg;
15
use function get_class;
16
use function htmlspecialchars;
17
use function is_dir;
18
use function ltrim;
19
use function ob_end_clean;
20
use function ob_get_level;
21
use function ob_start;
22
use function pathinfo;
23
use function rtrim;
24
use function sprintf;
25
use function trim;
26
27
final class Renderer
28
{
29
    /**
30
     * @var string path to the root directory of views.
31
     */
32
    private string $viewDirectory;
33
34
    /**
35
     * @var string file extension of the default views.
36
     */
37
    private string $fileExtension;
38
39
    /**
40
     * @var string|null name of the view layout.
41
     */
42
    private ?string $layout = null;
43
44
    /**
45
     * @var string|null name of the block currently being rendered.
46
     */
47
    private ?string $blockName = null;
48
49
    /**
50
     * @var array<string, string> of blocks content.
51
     */
52
    private array $blocks = [];
53
54
    /**
55
     * @var array<string, mixed> global variables that will be available in all views.
56
     */
57
    private array $globalVars = [];
58
59
    /**
60
     * @var array<string, ExtensionInterface>
61
     */
62
    private array $extensions = [];
63
64
    /**
65
     * @var Closure
66
     */
67
    private Closure $renderer;
68
69
    /**
70
     * @param string $viewDirectory path to the root directory of views.
71
     * @param string $fileExtension file extension of the default views.
72
     * @throws RuntimeException if the specified path does not exist.
73
     * @psalm-suppress MixedArgument
74
     * @psalm-suppress UnresolvableInclude
75
     */
76 16
    public function __construct(string $viewDirectory, string $fileExtension = 'php')
77
    {
78 16
        if (!is_dir($viewDirectory = rtrim($viewDirectory, '\/'))) {
79 1
            throw new RuntimeException(sprintf(
80 1
                'The specified view directory "%s" does not exist.',
81 1
                $viewDirectory
82 1
            ));
83
        }
84
85 16
        if ($fileExtension && $fileExtension[0] === '.') {
86
            $fileExtension = ltrim($fileExtension, '.');
87
        }
88
89 16
        $this->viewDirectory = $viewDirectory;
90 16
        $this->fileExtension = $fileExtension;
91 16
        $this->renderer = function (): void {
92 8
            extract(func_get_arg(1), EXTR_OVERWRITE);
93 8
            require func_get_arg(0);
94 16
        };
95
    }
96
97
    /**
98
     * Adds an extension.
99
     *
100
     * @param ExtensionInterface $extension
101
     */
102 1
    public function addExtension(ExtensionInterface $extension): void
103
    {
104 1
        $this->extensions[get_class($extension)] = $extension;
105
    }
106
107
    /**
108
     * Adds a global variable.
109
     *
110
     * @param string $name variable name.
111
     * @param mixed $value variable value.
112
     * @throws RuntimeException if this global variable has already been added.
113
     */
114 3
    public function addGlobal(string $name, $value): void
115
    {
116 3
        if (array_key_exists($name, $this->globalVars)) {
117 1
            throw new RuntimeException(sprintf(
118 1
                'Unable to add "%s" as this global variable has already been added.',
119 1
                $name
120 1
            ));
121
        }
122
123 3
        $this->globalVars[$name] = $value;
124
    }
125
126
    /**
127
     * @param string $layout name of the view layout.
128
     */
129 2
    public function layout(string $layout): void
130
    {
131 2
        $this->layout = $layout;
132
    }
133
134
    /**
135
     * Records a block.
136
     *
137
     * @param string $name block name.
138
     * @param string $content block content.
139
     * @throws RuntimeException if the specified block name is "content".
140
     */
141 5
    public function block(string $name, string $content): void
142
    {
143 5
        if ($name === 'content') {
144 2
            throw new RuntimeException('The block name "content" is reserved.');
145
        }
146
147 3
        if (!$name || array_key_exists($name, $this->blocks)) {
148 2
            return;
149
        }
150
151 3
        $this->blocks[$name] = $content;
152
    }
153
154
    /**
155
     * Begins recording a block.
156
     *
157
     * @param string $name block name.
158
     * @throws RuntimeException if you try to nest a block in other block.
159
     * @see block()
160
     */
161 3
    public function beginBlock(string $name): void
162
    {
163 3
        if ($this->blockName) {
164
            throw new RuntimeException('You cannot nest blocks within other blocks.');
165
        }
166
167 3
        $this->blockName = $name;
168 3
        ob_start();
169
    }
170
171
    /**
172
     * Ends recording a block.
173
     *
174
     * @throws RuntimeException If you try to end a block without beginning it.
175
     * @see block()
176
     */
177 4
    public function endBlock(): void
178
    {
179 4
        if ($this->blockName === null) {
180 1
            throw new RuntimeException('You must begin a block before can end it.');
181
        }
182
183 3
        $this->block($this->blockName, ob_get_clean());
184 2
        $this->blockName = null;
185
    }
186
187
    /**
188
     * Renders a block.
189
     *
190
     * @param string $name block name.
191
     * @param string $default default content.
192
     * @return string block content.
193
     */
194 3
    public function renderBlock(string $name, string $default = ''): string
195
    {
196 3
        return $this->blocks[$name] ?? $default;
197
    }
198
199
    /**
200
     * Renders a view.
201
     *
202
     * @param string $view view name.
203
     * @param array $params view variables (`name => value`).
204
     * @return string rendered view content.
205
     * @throws RuntimeException if the view file does not exist or is not a file.
206
     * @throws Throwable If an error occurred during rendering.
207
     * @psalm-suppress RedundantCondition, NoValue
208
     */
209 8
    public function render(string $view, array $params = []): string
210
    {
211 8
        $view = $this->viewDirectory . '/' . trim($view, '\/');
212
213 8
        if (pathinfo($view, PATHINFO_EXTENSION) === '') {
214 8
            $view .= ($this->fileExtension ? '.' . $this->fileExtension : '');
215
        }
216
217 8
        if (!file_exists($view) || !is_file($view)) {
218
            throw new RuntimeException(sprintf(
219
                'View file "%s" does not exist or is not a file.',
220
                $view
221
            ));
222
        }
223
224 8
        $level = ob_get_level();
225 8
        $this->layout = null;
226 8
        ob_start();
227
228
        try {
229 8
            ($this->renderer)($view, $params + $this->globalVars);
230 8
            $content = ob_get_clean();
231
        } catch (Throwable $e) {
232
            while (ob_get_level() > $level) {
233
                ob_end_clean();
234
            }
235
            throw $e;
236
        }
237
238 8
        if (!$this->layout) {
239 8
            return $content;
240
        }
241
242 1
        $this->blocks['content'] = $content;
243 1
        return $this->render($this->layout);
0 ignored issues
show
$this->layout of type void is incompatible with the type string expected by parameter $view of Devanych\View\Renderer::render(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

243
        return $this->render(/** @scrutinizer ignore-type */ $this->layout);
Loading history...
244
    }
245
246
    /**
247
     * Escapes special characters, converts them to corresponding HTML entities.
248
     *
249
     * @param string $content content to be escaped.
250
     * @return string escaped content.
251
     */
252 1
    public function esc(string $content): string
253
    {
254 1
        return htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true);
255
    }
256
257
    /**
258
     * Magic method used to call extension functions.
259
     *
260
     * @param string $name function name.
261
     * @param array $arguments function arguments.
262
     * @return mixed result of the function.
263
     * @throws RuntimeException if the extension or function was not added.
264
     */
265 1
    public function __call(string $name, array $arguments)
266
    {
267 1
        foreach ($this->extensions as $extension) {
268 1
            foreach ($extension->getFunctions() as $function => $callback) {
269 1
                if ($function === $name) {
270 1
                    return ($callback)(...$arguments);
271
                }
272
            }
273
        }
274
275
        throw new RuntimeException(sprintf('Calling an undefined function "%s".', $name));
276
    }
277
}
278