Passed
Pull Request — master (#183)
by Sergei
08:12
created

BaseView::hasCommonParameter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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