Passed
Push — master ( 2af18a...ebd8c1 )
by Alexander
03:43
created

BaseView::render()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 5
ccs 3
cts 3
cp 1
rs 10
cc 1
nc 1
nop 2
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\View;
6
7
use InvalidArgumentException;
8
use Psr\EventDispatcher\EventDispatcherInterface;
9
use Psr\EventDispatcher\StoppableEventInterface;
10
use RuntimeException;
11
use Throwable;
12
use Yiisoft\View\Event\AfterRenderEventInterface;
13
use Yiisoft\View\Exception\ViewNotFoundException;
14
15
use function dirname;
16
17
/**
18
 * @internal Base class for {@see View} and {@see WebView}.
19
 */
20
abstract class BaseView
21
{
22
    protected EventDispatcherInterface $eventDispatcher;
23
24
    /**
25
     * @var string Base view path.
26
     */
27
    private string $basePath;
28
29
    /**
30
     * @var Theme|null The theme object.
31
     */
32
    private ?Theme $theme = null;
33
34
    /**
35
     * @var ViewContextInterface|null The context under which the {@see renderFile()} method is being invoked.
36
     */
37
    private ?ViewContextInterface $context = null;
38
39
    private string $placeholderSignature;
40
41
    /**
42
     * @var array A list of available renderers indexed by their corresponding supported file extensions. Each renderer
43
     * may be a view renderer object or the configuration for creating the renderer object. For example, the
44
     * following configuration enables the Twig view renderer:
45
     *
46
     * ```php
47
     * [
48
     *     'twig' => ['class' => \Yiisoft\Yii\Twig\ViewRenderer::class],
49
     * ]
50
     * ```
51
     *
52
     * If no renderer is available for the given view file, the view file will be treated as a normal PHP and rendered
53
     * via PhpTemplateRenderer.
54
     */
55
    private array $renderers = [];
56
57
    private string $language = 'en';
58
    private string $sourceLanguage = 'en';
59
60
    /**
61
     * @var string The default view file extension. This will be appended to view file names if they don't have file
62
     * extensions.
63
     */
64
    private string $defaultExtension = 'php';
65
66
    /**
67
     * @var array<string, mixed> Parameters that are common for all view templates.
68
     */
69
    private array $commonParameters = [];
70
71
    /**
72
     * @var array<string, string> Named content blocks that are common for all view templates.
73
     */
74
    private array $blocks = [];
75
76
    /**
77
     * @var array The view files currently being rendered. There may be multiple view files being
78
     * rendered at a moment because one view may be rendered within another.
79
     */
80
    private array $viewFiles = [];
81
82 97
    public function __construct(string $basePath, EventDispatcherInterface $eventDispatcher)
83
    {
84 97
        $this->basePath = $basePath;
85 97
        $this->eventDispatcher = $eventDispatcher;
86 97
        $this->setPlaceholderSalt(__DIR__);
87 97
    }
88
89
    /**
90
     * @return static
91
     */
92 2
    public function withTheme(Theme $theme): self
93
    {
94 2
        $new = clone $this;
95 2
        $new->theme = $theme;
96 2
        return $new;
97
    }
98
99
    /**
100
     * @return static
101
     */
102 1
    public function withRenderers(array $renderers): self
103
    {
104 1
        $new = clone $this;
105 1
        $new->renderers = $renderers;
106 1
        return $new;
107
    }
108
109
    /**
110
     * @return static
111
     */
112 1
    public function withLanguage(string $language): self
113
    {
114 1
        $new = clone $this;
115 1
        $new->language = $language;
116 1
        return $new;
117
    }
118
119
    /**
120
     * @return static
121
     */
122 1
    public function withSourceLanguage(string $language): self
123
    {
124 1
        $new = clone $this;
125 1
        $new->sourceLanguage = $language;
126 1
        return $new;
127
    }
128
129
    /**
130
     * @return static
131
     */
132 3
    public function withContext(ViewContextInterface $context): self
133
    {
134 3
        $new = clone $this;
135 3
        $new->context = $context;
136 3
        return $new;
137
    }
138
139 1
    public function getBasePath(): string
140
    {
141 1
        return $this->basePath;
142
    }
143
144 47
    final public function getPlaceholderSignature(): string
145
    {
146 47
        return $this->placeholderSignature;
147
    }
148
149 97
    final public function setPlaceholderSalt(string $salt): void
150
    {
151 97
        $this->placeholderSignature = dechex(crc32($salt));
152 97
    }
153
154 1
    public function getDefaultExtension(): string
155
    {
156 1
        return $this->defaultExtension;
157
    }
158
159
    /**
160
     * @return static
161
     */
162 2
    public function withDefaultExtension(string $defaultExtension): self
163
    {
164 2
        $new = clone $this;
165 2
        $new->defaultExtension = $defaultExtension;
166 2
        return $new;
167
    }
168
169
    /**
170
     * Sets a common parameters that is accessible in all view templates.
171
     *
172
     * @param array<string, mixed> $commonParameters Parameters that are common for all view templates.
173
     *
174
     * @see setCommonParameter()
175
     */
176 1
    public function setCommonParameters(array $commonParameters): void
177
    {
178 1
        foreach ($commonParameters as $id => $value) {
179 1
            $this->setCommonParameter($id, $value);
180
        }
181 1
    }
182
183
    /**
184
     * Sets a common parameter that is accessible in all view templates.
185
     *
186
     * @param string $id The unique identifier of the parameter.
187
     * @param mixed $value The value of the parameter.
188
     */
189 3
    public function setCommonParameter(string $id, $value): void
190
    {
191 3
        $this->commonParameters[$id] = $value;
192 3
    }
193
194
    /**
195
     * Removes a common parameter.
196
     *
197
     * @param string $id The unique identifier of the parameter.
198
     */
199 1
    public function removeCommonParameter(string $id): void
200
    {
201 1
        unset($this->commonParameters[$id]);
202 1
    }
203
204
    /**
205
     * Gets a common parameter value by ID.
206
     *
207
     * @param string $id The unique identifier of the parameter.
208
     *
209
     * @return mixed The value of the parameter.
210
     */
211 1
    public function getCommonParameter(string $id)
212
    {
213 1
        if (isset($this->commonParameters[$id])) {
214 1
            return $this->commonParameters[$id];
215
        }
216
217 1
        throw new InvalidArgumentException('Common parameter: "' . $id . '" not found.');
218
    }
219
220
    /**
221
     * Checks the existence of a common parameter by ID.
222
     *
223
     * @param string $id The unique identifier of the parameter.
224
     *
225
     * @return bool Whether a custom parameter that is common for all view templates exists.
226
     */
227 1
    public function hasCommonParameter(string $id): bool
228
    {
229 1
        return isset($this->commonParameters[$id]);
230
    }
231
232
    /**
233
     * Sets a content block.
234
     *
235
     * @param string $id The unique identifier of the block.
236
     * @param mixed $content The content of the block.
237
     */
238 1
    public function setBlock(string $id, string $content): void
239
    {
240 1
        $this->blocks[$id] = $content;
241 1
    }
242
243
    /**
244
     * Removes a content block.
245
     *
246
     * @param string $id The unique identifier of the block.
247
     */
248 1
    public function removeBlock(string $id): void
249
    {
250 1
        unset($this->blocks[$id]);
251 1
    }
252
253
    /**
254
     * Gets content of the block by ID.
255
     *
256
     * @param string $id The unique identifier of the block.
257
     *
258
     * @return string The content of the block.
259
     */
260 1
    public function getBlock(string $id): string
261
    {
262 1
        if (isset($this->blocks[$id])) {
263 1
            return $this->blocks[$id];
264
        }
265
266 1
        throw new InvalidArgumentException('Block: "' . $id . '" not found.');
267
    }
268
269
    /**
270
     * Checks the existence of a content block by ID.
271
     *
272
     * @param string $id The unique identifier of the block.
273
     *
274
     * @return bool Whether a content block exists.
275
     */
276 1
    public function hasBlock(string $id): bool
277
    {
278 1
        return isset($this->blocks[$id]);
279
    }
280
281
    /**
282
     * @return string|null The view file currently being rendered. `null` if no view file is being rendered.
283
     */
284 1
    public function getViewFile(): ?string
285
    {
286 1
        return empty($this->viewFiles) ? null : end($this->viewFiles)['resolved'];
287
    }
288
289
    /**
290
     * Renders a view.
291
     *
292
     * The view to be rendered can be specified in one of the following formats:
293
     *
294
     * - [path alias](guide:concept-aliases) (e.g. "@app/views/site/index");
295
     * - absolute path within application (e.g. "//site/index"): the view name starts with double slashes. The actual
296
     *   view file will be looked for under the [[Application::viewPath|view path]] of the application.
297
     * - absolute path within current module (e.g. "/site/index"): the view name starts with a single slash. The actual
298
     *   view file will be looked for under the [[Module::viewPath|view path]]
299
     *   of the [[Controller::module|current module]].
300
     * - relative view (e.g. "index"): the view name does not start with `@` or `/`. The corresponding view file will be
301
     *   looked for under the {@see ViewContextInterface::getViewPath()} of the {@see View::$context}.
302
     *   If {@see View::$context} is not set, it will be looked for under the directory containing the view currently
303
     *   being rendered (i.e., this happens when rendering a view within another view).
304
     *
305
     * @param string $view the view name.
306
     * @param array $parameters the parameters (name-value pairs) that will be extracted and made available in the view
307
     * file.
308
     *
309
     * @throws RuntimeException If the view cannot be resolved.
310
     * @throws ViewNotFoundException If the view file does not exist.
311
     * @throws Throwable
312
     *
313
     * {@see renderFile()}
314
     *
315
     * @return string The rendering result.
316
     */
317 49
    public function render(string $view, array $parameters = []): string
318
    {
319 49
        $viewFile = $this->findTemplateFile($view);
320
321 48
        return $this->renderFile($viewFile, $parameters);
322
    }
323
324
    /**
325
     * Renders a view file.
326
     *
327
     * If {@see theme} is enabled (not null), it will try to render the themed version of the view file as long as it
328
     * is available.
329
     *
330
     * If {@see renderers} is enabled (not null), the method will use it to render the view file. Otherwise,
331
     * it will simply include the view file as a normal PHP file, capture its output and
332
     * return it as a string.
333
     *
334
     * @param string $viewFile The view file. This can be either an absolute file path or an alias of it.
335
     * @param array $parameters The parameters (name-value pairs) that will be extracted and made available in the view
336
     * file.
337
     *
338
     * @throws Throwable
339
     * @throws ViewNotFoundException If the view file does not exist
340
     *
341
     * @return string The rendering result.
342
     */
343 52
    public function renderFile(string $viewFile, array $parameters = []): string
344
    {
345 52
        $parameters = array_merge($this->commonParameters, $parameters);
346
347
        // TODO: these two match now
348 52
        $requestedFile = $viewFile;
349
350 52
        if ($this->theme !== null) {
351 1
            $viewFile = $this->theme->applyTo($viewFile);
352
        }
353
354 52
        if (is_file($viewFile)) {
355 51
            $viewFile = $this->localize($viewFile);
356
        } else {
357 1
            throw new ViewNotFoundException("The view file \"$viewFile\" does not exist.");
358
        }
359
360 51
        $output = '';
361 51
        $this->viewFiles[] = [
362 51
            'resolved' => $viewFile,
363 51
            'requested' => $requestedFile,
364
        ];
365
366 51
        if ($this->beforeRender($viewFile, $parameters)) {
367 51
            $ext = pathinfo($viewFile, PATHINFO_EXTENSION);
368 51
            $renderer = $this->renderers[$ext] ?? new PhpTemplateRenderer();
369 51
            $output = $renderer->render($this, $viewFile, $parameters);
370 51
            $output = $this->afterRender($viewFile, $parameters, $output);
371
        }
372
373 51
        array_pop($this->viewFiles);
374
375 51
        return $output;
376
    }
377
378
    /**
379
     * Returns the localized version of a specified file.
380
     *
381
     * The searching is based on the specified language code. In particular, a file with the same name will be looked
382
     * for under the subdirectory whose name is the same as the language code. For example, given the file
383
     * "path/to/view.php" and language code "zh-CN", the localized file will be looked for as path/to/zh-CN/view.php".
384
     * If the file is not found, it will try a fallback with just a language code that is "zh"
385
     * i.e. "path/to/zh/view.php".
386
     * If it is not found as well the original file will be returned.
387
     *
388
     * If the target and the source language codes are the same, the original file will be returned.
389
     *
390
     * @param string $file the original file
391
     * @param string|null $language the target language that the file should be localized to.
392
     * @param string|null $sourceLanguage the language that the original file is in.
393
     *
394
     * @return string the matching localized file, or the original file if the localized version is not found.
395
     * If the target and the source language codes are the same, the original file will be returned.
396
     */
397 53
    public function localize(string $file, ?string $language = null, ?string $sourceLanguage = null): string
398
    {
399 53
        $language = $language ?? $this->language;
400 53
        $sourceLanguage = $sourceLanguage ?? $this->sourceLanguage;
401
402 53
        if ($language === $sourceLanguage) {
403 53
            return $file;
404
        }
405 2
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
406 2
        if (is_file($desiredFile)) {
407 2
            return $desiredFile;
408
        }
409
410 1
        $language = substr($language, 0, 2);
411 1
        if ($language === $sourceLanguage) {
412 1
            return $file;
413
        }
414 1
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
415
416 1
        return is_file($desiredFile) ? $desiredFile : $file;
417
    }
418
419
    /**
420
     * This method is invoked right before {@see renderFile()} renders a view file.
421
     *
422
     * The default implementation will trigger the {@see BeforeRender()} event. If you override this method, make sure
423
     * you call the parent implementation first.
424
     *
425
     * @param string $viewFile the view file to be rendered.
426
     * @param array $parameters the parameter array passed to the {@see render()} method.
427
     *
428
     * @return bool whether to continue rendering the view file.
429
     */
430 51
    public function beforeRender(string $viewFile, array $parameters): bool
431
    {
432 51
        $event = $this->createBeforeRenderEvent($viewFile, $parameters);
433 51
        $event = $this->eventDispatcher->dispatch($event);
434
435 51
        return !$event->isPropagationStopped();
436
    }
437
438
    abstract protected function createBeforeRenderEvent(string $viewFile, array $parameters): StoppableEventInterface;
439
440
    /**
441
     * This method is invoked right after {@see renderFile()} renders a view file.
442
     *
443
     * The default implementation will trigger the {@see AfterRender} event. If you override this method, make sure you
444
     * call the parent implementation first.
445
     *
446
     * @param string $viewFile the view file being rendered.
447
     * @param array $parameters the parameter array passed to the {@see render()} method.
448
     * @param string $output the rendering result of the view file.
449
     *
450
     * @return string Updated output. It will be passed to {@see renderFile()} and returned.
451
     */
452 51
    public function afterRender(string $viewFile, array $parameters, string $output): string
453
    {
454 51
        $event = $this->createAfterRenderEvent($viewFile, $parameters, $output);
455
456
        /** @var AfterRenderEventInterface $event */
457 51
        $event = $this->eventDispatcher->dispatch($event);
458
459 51
        return $event->getResult();
460
    }
461
462
    abstract protected function createAfterRenderEvent(
463
        string $viewFile,
464
        array $parameters,
465
        string $result
466
    ): AfterRenderEventInterface;
467
468
    /**
469
     * Clears the data for working with the event loop.
470
     */
471 46
    public function clear(): void
472
    {
473 46
        $this->viewFiles = [];
474 46
    }
475
476
    /**
477
     * Finds the view file based on the given view name.
478
     *
479
     * @param string $view The view name or the [path alias](guide:concept-aliases) of the view file. Please refer to
480
     * {@see render()} on how to specify this parameter.
481
     *
482
     * @throws RuntimeException If a relative view name is given while there is no active context to determine the
483
     * corresponding view file.
484
     *
485
     * @return string The view file path. Note that the file may not exist.
486
     */
487 51
    protected function findTemplateFile(string $view): string
488
    {
489 51
        if (strncmp($view, '//', 2) === 0) {
490
            // path relative to basePath e.g. "//layouts/main"
491 48
            $file = $this->basePath . '/' . ltrim($view, '/');
492 4
        } elseif (($currentViewFile = $this->getRequestedViewFile()) !== null) {
493
            // path relative to currently rendered view
494 2
            $file = dirname($currentViewFile) . '/' . $view;
495 3
        } elseif ($this->context instanceof ViewContextInterface) {
496
            // path provided by context
497 2
            $file = $this->context->getViewPath() . '/' . $view;
498
        } else {
499 1
            throw new RuntimeException("Unable to resolve view file for view \"$view\": no active view context.");
500
        }
501
502 50
        if (pathinfo($file, PATHINFO_EXTENSION) !== '') {
503 45
            return $file;
504
        }
505
506 5
        $path = $file . '.' . $this->defaultExtension;
507
508 5
        if ($this->defaultExtension !== 'php' && !is_file($path)) {
509 1
            $path = $file . '.php';
510
        }
511
512 5
        return $path;
513
    }
514
515
    /**
516
     * @return string|null The requested view currently being rendered. `null` if no view file is being rendered.
517
     */
518 4
    private function getRequestedViewFile(): ?string
519
    {
520 4
        return empty($this->viewFiles) ? null : end($this->viewFiles)['requested'];
521
    }
522
}
523