Passed
Push — master ( d2c063...2d8d97 )
by Alexander
04:47 queued 02:06
created

BaseView::renderDynamic()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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