Passed
Pull Request — master (#243)
by
unknown
13:40
created

ViewTrait::withTheme()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 6
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 1
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
use Yiisoft\View\State\LocaleState;
15
use function array_merge;
16
use function array_pop;
17
use function basename;
18
use function call_user_func_array;
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
 * `ViewTrait` could be used as a base implementation of {@see ViewInterface}.
30
 *
31
 * @internal
32
 */
33
trait ViewTrait
34
{
35
    private EventDispatcherInterface $eventDispatcher;
36
37
    private string $basePath;
38
    private ?ViewContextInterface $context = null;
39
    private string $placeholderSignature;
40
    private string $sourceLocale = 'en';
41
    /**
42
     * @var string[]
43
     */
44
    private array $fallbackExtensions = [self::PHP_EXTENSION];
45
46
    /**
47
     * @var array A list of available renderers indexed by their corresponding
48
     * supported file extensions.
49
     * @psalm-var array<string, TemplateRendererInterface>
50
     */
51
    private array $renderers = [];
52
53
    /**
54
     * @var array The view files currently being rendered. There may be multiple view files being
55
     * rendered at a moment because one view may be rendered within another.
56
     *
57
     * @psalm-var array<array-key, array<string, string>>
58
     */
59
    private array $viewFiles = [];
60
61
    /**
62
     * Returns a new instance with specified base path to the view directory.
63
     *
64
     * @param string $basePath The base path to the view directory.
65
     */
66 2
    public function withBasePath(string $basePath): static
67
    {
68 2
        $new = clone $this;
69 2
        $new->basePath = $basePath;
70 2
        return $new;
71
    }
72
73
    /**
74
     * Returns a new instance with the specified renderers.
75
     *
76
     * @param array $renderers A list of available renderers indexed by their
77
     * corresponding supported file extensions.
78
     *
79
     * ```php
80
     * $view = $view->withRenderers(['twig' => new \Yiisoft\View\Twig\ViewRenderer($environment)]);
81
     * ```
82
     *
83
     * If no renderer is available for the given view file, the view file will be treated as a normal PHP
84
     * and rendered via {@see PhpTemplateRenderer}.
85
     *
86
     * @psalm-param array<string, TemplateRendererInterface> $renderers
87
     */
88 3
    public function withRenderers(array $renderers): static
89
    {
90 3
        $new = clone $this;
91 3
        $new->renderers = $renderers;
92 3
        return $new;
93
    }
94
95
    /**
96
     * Returns a new instance with the specified source locale.
97
     *
98
     * @param string $locale The source locale.
99
     */
100 3
    public function withSourceLocale(string $locale): static
101
    {
102 3
        $new = clone $this;
103 3
        $new->sourceLocale = $locale;
104 3
        return $new;
105
    }
106
107
    /**
108
     * Returns a new instance with the specified default view file extension.
109
     *
110
     * @param string $defaultExtension The default view file extension. Default is {@see ViewInterface::PHP_EXTENSION}.
111
     * This will be appended to view file names if they don't have file extensions.
112
     * @deprecated Since 8.0.1 and will be removed in the next major version. Use {@see withFallbackExtension()} instead.
113
     */
114 8
    public function withDefaultExtension(string $defaultExtension): static
115
    {
116 8
        return $this->withFallbackExtension($defaultExtension);
117
    }
118
119
    /**
120
     * Returns a new instance with the specified fallback view file extension.
121
     *
122
     * @param string $fallbackExtension The fallback view file extension. Default is {@see ViewInterface::PHP_EXTENSION}.
123
     * This will be appended to view file names if they don't exist.
124
     */
125 8
    public function withFallbackExtension(string $fallbackExtension, string ...$otherFallbacks): static
126
    {
127 8
        $new = clone $this;
128 8
        $new->fallbackExtensions = [$fallbackExtension, ...array_values($otherFallbacks)];
0 ignored issues
show
Documentation Bug introduced by
array($fallbackExtension...alues($otherFallbacks)) is of type array<integer,array|string>, but the property $fallbackExtensions was declared to be of type string[]. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
129 8
        return $new;
130
    }
131
132
    /**
133
     * Returns a new instance with the specified view context instance.
134
     *
135
     * @param ViewContextInterface $context The context under which the {@see renderFile()} method is being invoked.
136
     */
137 9
    public function withContext(ViewContextInterface $context): static
138
    {
139 9
        $new = clone $this;
140 9
        $new->context = $context;
141 9
        $new->viewFiles = [];
142 9
        return $new;
143
    }
144
145
    /**
146
     * Returns a new instance with the specified view context path.
147
     *
148
     * @param string $path The context path under which the {@see renderFile()} method is being invoked.
149
     */
150 2
    public function withContextPath(string $path): static
151
    {
152 2
        return $this->withContext(new ViewContext($path));
153
    }
154
155
    /**
156
     * Returns a new instance with specified salt for the placeholder signature {@see getPlaceholderSignature()}.
157
     *
158
     * @param string $salt The placeholder salt.
159
     */
160 2
    public function withPlaceholderSalt(string $salt): static
161
    {
162 2
        $new = clone $this;
163 2
        $new->setPlaceholderSalt($salt);
164 2
        return $new;
165
    }
166
167
    /**
168
     * Set the specified locale code.
169
     *
170
     * @param string $locale The locale code.
171
     */
172 3
    public function setLocale(string $locale): static
173
    {
174 3
        $this->localeState->setLocale($locale);
175 3
        return $this;
176
    }
177
178
    /**
179
     * Set the specified locale code.
180
     *
181
     * @param string $locale The locale code.
182
     */
183 3
    public function withLocale(string $locale): static
184
    {
185 3
        $new = clone $this;
186 3
        $new->localeState = new LocaleState($locale);
0 ignored issues
show
Bug Best Practice introduced by
The property localeState does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
187
188 3
        return $new;
189
    }
190
191
    /**
192
     * Get the specified locale code.
193
     *
194
     * @return string The locale code.
195
     */
196 1
    public function getLocale(): string
197
    {
198 1
        return $this->localeState->getLocale();
199
    }
200
201
    /**
202
     * Gets the base path to the view directory.
203
     *
204
     * @return string The base view path.
205
     */
206 2
    public function getBasePath(): string
207
    {
208 2
        return $this->basePath;
209
    }
210
211
    /**
212
     * Gets the default view file extension.
213
     *
214
     * @return string The default view file extension.
215
     * @deprecated Since 8.0.1 and will be removed in the next major version. Use {@see getFallbackExtensions()} instead.
216
     */
217 1
    public function getDefaultExtension(): string
218
    {
219 1
        return $this->getFallbackExtensions()[0];
220
    }
221
222
    /**
223
     * Gets the fallback view file extension.
224
     *
225
     * @return string[] The fallback view file extension.
226
     */
227 1
    public function getFallbackExtensions(): array
228
    {
229 1
        return $this->fallbackExtensions;
230
    }
231
232
    /**
233
     * Gets the theme instance, or `null` if no theme has been set.
234
     *
235
     * @return Theme|null The theme instance, or `null` if no theme has been set.
236
     */
237 65
    public function getTheme(): ?Theme
238
    {
239 65
        return $this->state->getTheme();
240
    }
241
242
    /**
243
     * Set the specified theme instance.
244
     *
245
     * @param Theme|null $theme The theme instance or `null` for reset theme.
246
     */
247 3
    public function setTheme(?Theme $theme): static
248
    {
249 3
        $this->state->setTheme($theme);
250 3
        return $this;
251
    }
252
253 2
    public function withTheme(?Theme $theme): static
254
    {
255 2
        $new = clone $this;
256 2
        $new->state = clone $this->state;
0 ignored issues
show
Bug Best Practice introduced by
The property state does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
257
258 2
        return $new->setTheme($theme);
259
    }
260
261
    /**
262
     * Sets a common parameters that is accessible in all view templates.
263
     *
264
     * @param array $parameters Parameters that are common for all view templates.
265
     *
266
     * @psalm-param array<string, mixed> $parameters
267
     *
268
     * @see setParameter()
269
     */
270 5
    public function setParameters(array $parameters): static
271
    {
272 5
        $this->state->setParameters($parameters);
273 5
        return $this;
274
    }
275
276
    /**
277
     * Sets a common parameter that is accessible in all view templates.
278
     *
279
     * @param string $id The unique identifier of the parameter.
280
     * @param mixed $value The value of the parameter.
281
     */
282 12
    public function setParameter(string $id, mixed $value): static
283
    {
284 12
        $this->state->setParameter($id, $value);
285 12
        return $this;
286
    }
287
288
    /**
289
     * Add values to end of common array parameter. If specified parameter does not exist or him is not array,
290
     * then parameter will be added as empty array.
291
     *
292
     * @param string $id The unique identifier of the parameter.
293
     * @param mixed ...$value Value(s) for add to end of array parameter.
294
     *
295
     * @throws InvalidArgumentException When specified parameter already exists and is not an array.
296
     */
297 6
    public function addToParameter(string $id, mixed ...$value): static
298
    {
299 6
        $this->state->addToParameter($id, ...$value);
300 5
        return $this;
301
    }
302
303
    /**
304
     * Removes a common parameter.
305
     *
306
     * @param string $id The unique identifier of the parameter.
307
     */
308 3
    public function removeParameter(string $id): static
309
    {
310 3
        $this->state->removeParameter($id);
311 3
        return $this;
312
    }
313
314
    /**
315
     * Gets a common parameter value by ID.
316
     *
317
     * @param string $id The unique identifier of the parameter.
318
     * @param mixed $default The default value to be returned if the specified parameter does not exist.
319
     *
320
     * @throws InvalidArgumentException If specified parameter does not exist and not passed default value.
321
     *
322
     * @return mixed The value of the parameter.
323
     */
324 8
    public function getParameter(string $id)
325
    {
326 8
        return call_user_func_array([$this->state, 'getParameter'], func_get_args());
327
    }
328
329
    /**
330
     * Checks the existence of a common parameter by ID.
331
     *
332
     * @param string $id The unique identifier of the parameter.
333
     *
334
     * @return bool Whether a custom parameter that is common for all view templates exists.
335
     */
336 5
    public function hasParameter(string $id): bool
337
    {
338 5
        return $this->state->hasParameter($id);
339
    }
340
341
    /**
342
     * Sets a content block.
343
     *
344
     * @param string $id The unique identifier of the block.
345
     * @param string $content The content of the block.
346
     */
347 7
    public function setBlock(string $id, string $content): static
348
    {
349 7
        $this->state->setBlock($id, $content);
350 7
        return $this;
351
    }
352
353
    /**
354
     * Removes a content block.
355
     *
356
     * @param string $id The unique identifier of the block.
357
     */
358 3
    public function removeBlock(string $id): static
359
    {
360 3
        $this->state->removeBlock($id);
361 3
        return $this;
362
    }
363
364
    /**
365
     * Gets content of the block by ID.
366
     *
367
     * @param string $id The unique identifier of the block.
368
     *
369
     * @return string The content of the block.
370
     */
371 2
    public function getBlock(string $id): string
372
    {
373 2
        return $this->state->getBlock($id);
374
    }
375
376
    /**
377
     * Checks the existence of a content block by ID.
378
     *
379
     * @param string $id The unique identifier of the block.
380
     *
381
     * @return bool Whether a content block exists.
382
     */
383 5
    public function hasBlock(string $id): bool
384
    {
385 5
        return $this->state->hasBlock($id);
386
    }
387
388
    /**
389
     * Gets the view file currently being rendered.
390
     *
391
     * @return string|null The view file currently being rendered. `null` if no view file is being rendered.
392
     */
393 5
    public function getViewFile(): ?string
394
    {
395
        /** @psalm-suppress InvalidArrayOffset */
396 5
        return empty($this->viewFiles) ? null : end($this->viewFiles)['resolved'];
397
    }
398
399
    /**
400
     * Gets the placeholder signature.
401
     *
402
     * @return string The placeholder signature.
403
     */
404 49
    public function getPlaceholderSignature(): string
405
    {
406 49
        return $this->placeholderSignature;
407
    }
408
409
    /**
410
     * Renders a view.
411
     *
412
     * The view to be rendered can be specified in one of the following formats:
413
     *
414
     * - The name of the view starting with a slash to join the base path {@see getBasePath()} (e.g. "/site/index").
415
     * - The name of the view without the starting slash (e.g. "site/index"). The corresponding view file will be
416
     *   looked for under the {@see ViewContextInterface::getViewPath()} of the context set via {@see withContext()}.
417
     *   If the context instance was not set {@see withContext()}, it will be looked for under the directory containing
418
     *   the view currently being rendered (i.e., this happens when rendering a view within another view).
419
     *
420
     * @param string $view The view name.
421
     * @param array $parameters The parameters (name-value pairs) that will be extracted and made available in the view
422
     * file.
423
     *
424
     * @throws RuntimeException If the view cannot be resolved.
425
     * @throws ViewNotFoundException If the view file does not exist.
426
     * @throws Throwable
427
     *
428
     * {@see renderFile()}
429
     *
430
     * @return string The rendering result.
431
     */
432 60
    public function render(string $view, array $parameters = []): string
433
    {
434 60
        $viewFile = $this->findTemplateFile($view);
435
436 59
        return $this->renderFile($viewFile, $parameters);
437
    }
438
439
    /**
440
     * Renders a view file.
441
     *
442
     * If the theme was set {@see setTheme()}, it will try to render the themed version of the view file
443
     * as long as it's available.
444
     *
445
     * If the renderer was set {@see withRenderers()}, the method will use it to render the view file. Otherwise,
446
     * it will simply include the view file as a normal PHP file, capture its output and return it as a string.
447
     *
448
     * @param string $viewFile The full absolute path of the view file.
449
     * @param array $parameters The parameters (name-value pairs) that will be extracted and made available in the view
450
     * file.
451
     *
452
     * @throws Throwable
453
     * @throws ViewNotFoundException If the view file doesn't exist
454
     *
455
     * @return string The rendering result.
456
     */
457 64
    public function renderFile(string $viewFile, array $parameters = []): string
458
    {
459 64
        $parameters = array_merge($this->state->getParameters(), $parameters);
460
461
        // TODO: these two match now
462 64
        $requestedFile = $viewFile;
463
464 64
        $theme = $this->getTheme();
465 64
        if ($theme !== null) {
466 1
            $viewFile = $theme->applyTo($viewFile);
467
        }
468
469 64
        if (is_file($viewFile)) {
470 63
            $viewFile = $this->localize($viewFile);
471
        } else {
472 1
            throw new ViewNotFoundException("The view file \"$viewFile\" does not exist.");
473
        }
474
475 63
        $output = '';
476 63
        $this->viewFiles[] = [
477 63
            'resolved' => $viewFile,
478 63
            'requested' => $requestedFile,
479 63
        ];
480
481 63
        if ($this->beforeRender($viewFile, $parameters)) {
482 63
            $ext = pathinfo($viewFile, PATHINFO_EXTENSION);
483 63
            $renderer = $this->renderers[$ext] ?? new PhpTemplateRenderer();
484 63
            $output = $renderer->render($this, $viewFile, $parameters);
0 ignored issues
show
Bug introduced by
$this of type Yiisoft\View\ViewTrait is incompatible with the type Yiisoft\View\ViewInterface expected by parameter $view of Yiisoft\View\PhpTemplateRenderer::render(). ( Ignorable by Annotation )

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

484
            $output = $renderer->render(/** @scrutinizer ignore-type */ $this, $viewFile, $parameters);
Loading history...
485 62
            $output = $this->afterRender($viewFile, $parameters, $output);
486
        }
487
488 62
        array_pop($this->viewFiles);
489
490 62
        return $output;
491
    }
492
493
    /**
494
     * Returns the localized version of a specified file.
495
     *
496
     * The searching is based on the specified locale code. In particular, a file with the same name will be looked
497
     * for under the subdirectory whose name is the same as the locale code. For example, given the file
498
     * "path/to/view.php" and locale code "zh-CN", the localized file will be looked for as "path/to/zh-CN/view.php".
499
     * If the file is not found, it will try a fallback with just a locale code that is "zh"
500
     * i.e. "path/to/zh/view.php".
501
     * If it is not found as well the original file will be returned.
502
     *
503
     * If the target and the source locale codes are the same, the original file will be returned.
504
     *
505
     * @param string $file The original file
506
     * @param string|null $locale The target locale that the file should be localized to.
507
     * @param string|null $sourceLocale The locale that the original file is in.
508
     *
509
     * @return string The matching localized file, or the original file if the localized version is not found.
510
     * If the target and the source locale codes are the same, the original file will be returned.
511
     */
512 66
    public function localize(string $file, ?string $locale = null, ?string $sourceLocale = null): string
513
    {
514 66
        $locale ??= $this->localeState->getLocale();
515 66
        $sourceLocale ??= $this->sourceLocale;
516
517 66
        if ($locale === $sourceLocale) {
518 64
            return $file;
519
        }
520
521 6
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $locale . DIRECTORY_SEPARATOR . basename($file);
522
523 6
        if (is_file($desiredFile)) {
524 6
            return $desiredFile;
525
        }
526
527 1
        $locale = substr($locale, 0, 2);
528
529 1
        if ($locale === $sourceLocale) {
530 1
            return $file;
531
        }
532
533 1
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $locale . DIRECTORY_SEPARATOR . basename($file);
534 1
        return is_file($desiredFile) ? $desiredFile : $file;
535
    }
536
537
    /**
538
     * Clears the data for working with the event loop.
539
     */
540 2
    public function clear(): void
541
    {
542 2
        $this->viewFiles = [];
543 2
        $this->state->clear();
544 2
        $this->localeState = new LocaleState();
0 ignored issues
show
Bug Best Practice introduced by
The property localeState does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
545
    }
546
547
    /**
548
     * Creates an event that occurs before rendering.
549
     *
550
     * @param string $viewFile The view file to be rendered.
551
     * @param array $parameters The parameter array passed to the {@see renderFile()} method.
552
     *
553
     * @return StoppableEventInterface The stoppable event instance.
554
     */
555
    abstract protected function createBeforeRenderEvent(string $viewFile, array $parameters): StoppableEventInterface;
556
557
    /**
558
     * Creates an event that occurs after rendering.
559
     *
560
     * @param string $viewFile The view file being rendered.
561
     * @param array $parameters The parameter array passed to the {@see renderFile()} method.
562
     * @param string $result The rendering result of the view file.
563
     *
564
     * @return AfterRenderEventInterface The event instance.
565
     */
566
    abstract protected function createAfterRenderEvent(
567
        string $viewFile,
568
        array $parameters,
569
        string $result
570
    ): AfterRenderEventInterface;
571
572
    /**
573
     * This method is invoked right before {@see renderFile()} renders a view file.
574
     *
575
     * The default implementations will trigger the {@see \Yiisoft\View\Event\View\BeforeRender}
576
     * or {@see \Yiisoft\View\Event\WebView\BeforeRender} event. If you override this method,
577
     * make sure you call the parent implementation first.
578
     *
579
     * @param string $viewFile The view file to be rendered.
580
     * @param array $parameters The parameter array passed to the {@see renderFile()} method.
581
     *
582
     * @return bool Whether to continue rendering the view file.
583
     */
584 63
    private function beforeRender(string $viewFile, array $parameters): bool
585
    {
586 63
        $event = $this->createBeforeRenderEvent($viewFile, $parameters);
587 63
        $event = $this->eventDispatcher->dispatch($event);
588
        /** @var StoppableEventInterface $event */
589 63
        return !$event->isPropagationStopped();
590
    }
591
592
    /**
593
     * This method is invoked right after {@see renderFile()} renders a view file.
594
     *
595
     * The default implementations will trigger the {@see \Yiisoft\View\Event\View\AfterRender}
596
     * or {@see \Yiisoft\View\Event\WebView\AfterRender} event. If you override this method,
597
     * make sure you call the parent implementation first.
598
     *
599
     * @param string $viewFile The view file being rendered.
600
     * @param array $parameters The parameter array passed to the {@see renderFile()} method.
601
     * @param string $result The rendering result of the view file.
602
     *
603
     * @return string Updated output. It will be passed to {@see renderFile()} and returned.
604
     */
605 62
    private function afterRender(string $viewFile, array $parameters, string $result): string
606
    {
607 62
        $event = $this->createAfterRenderEvent($viewFile, $parameters, $result);
608
609
        /** @var AfterRenderEventInterface $event */
610 62
        $event = $this->eventDispatcher->dispatch($event);
611
612 62
        return $event->getResult();
613
    }
614
615 130
    private function setPlaceholderSalt(string $salt): void
616
    {
617 130
        $this->placeholderSignature = dechex(crc32($salt));
618
    }
619
620
    /**
621
     * Finds the view file based on the given view name.
622
     *
623
     * @param string $view The view name of the view file. Please refer to
624
     * {@see render()} on how to specify this parameter.
625
     *
626
     * @throws RuntimeException If a relative view name is given while there is no active context to determine the
627
     * corresponding view file.
628
     *
629
     * @return string The view file path. Note that the file may not exist.
630
     */
631 62
    private function findTemplateFile(string $view): string
632
    {
633 62
        if ($view !== '' && $view[0] === '/') {
634
            // path relative to basePath e.g. "/layouts/main"
635 54
            $file = $this->basePath . '/' . ltrim($view, '/');
636 11
        } elseif (($currentViewFile = $this->getRequestedViewFile()) !== null) {
637
            // path relative to currently rendered view
638 3
            $file = dirname($currentViewFile) . '/' . $view;
639 9
        } elseif ($this->context instanceof ViewContextInterface) {
640
            // path provided by context
641 8
            $file = $this->context->getViewPath() . '/' . $view;
642
        } else {
643 1
            throw new RuntimeException("Unable to resolve view file for view \"$view\": no active view context.");
644
        }
645
646 61
        if (pathinfo($file, PATHINFO_EXTENSION) !== '' && is_file($file)) {
647 47
            return $file;
648
        }
649
650 14
        foreach ($this->fallbackExtensions as $fallbackExtension) {
651 14
            $fileWithFallbackExtension = $file . '.' . $fallbackExtension;
652 14
            if (is_file($fileWithFallbackExtension)) {
653 14
                return $fileWithFallbackExtension;
654
            }
655
        }
656
657 1
        return $file . '.' . $this->fallbackExtensions[0];
658
    }
659
660
    /**
661
     * @return string|null The requested view currently being rendered. `null` if no view file is being rendered.
662
     */
663 11
    private function getRequestedViewFile(): ?string
664
    {
665
        /** @psalm-suppress InvalidArrayOffset */
666 11
        return empty($this->viewFiles) ? null : end($this->viewFiles)['requested'];
667
    }
668
}
669