Passed
Pull Request — master (#232)
by Rustam
03:00
created

ViewTrait::withFallbackExtension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 10
c 0
b 0
f 0
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
16
use function array_merge;
17
use function array_pop;
18
use function basename;
19
use function call_user_func_array;
0 ignored issues
show
introduced by
The function call_user_func_array was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
20
use function crc32;
21
use function dechex;
22
use function dirname;
23
use function end;
24
use function func_get_args;
25
use function is_file;
26
use function pathinfo;
27
use function substr;
28
29
/**
30
 * `ViewTrait` could be used as a base implementation of {@see ViewInterface}.
31
 *
32
 * @internal
33
 */
34
trait ViewTrait
35
{
36
    private EventDispatcherInterface $eventDispatcher;
37
38
    private string $basePath;
39
    private ?ViewContextInterface $context = null;
40
    private string $placeholderSignature;
41
    private string $sourceLocale = 'en';
42
    private string $fallbackExtension = self::PHP_EXTENSION;
43
44
    /**
45
     * @var array A list of available renderers indexed by their corresponding
46
     * supported file extensions.
47
     * @psalm-var array<string, TemplateRendererInterface>
48
     */
49
    private array $renderers = [];
50
51
    /**
52
     * @var array The view files currently being rendered. There may be multiple view files being
53
     * rendered at a moment because one view may be rendered within another.
54
     *
55
     * @psalm-var array<array-key, array<string, string>>
56
     */
57
    private array $viewFiles = [];
58
59
    /**
60
     * Returns a new instance with specified base path to the view directory.
61
     *
62
     * @param string $basePath The base path to the view directory.
63
     */
64 2
    public function withBasePath(string $basePath): static
65
    {
66 2
        $new = clone $this;
67 2
        $new->basePath = $basePath;
68 2
        return $new;
69
    }
70
71
    /**
72
     * Returns a new instance with the specified renderers.
73
     *
74
     * @param array $renderers A list of available renderers indexed by their
75
     * corresponding supported file extensions.
76
     *
77
     * ```php
78
     * $view = $view->withRenderers(['twig' => new \Yiisoft\View\Twig\ViewRenderer($environment)]);
79
     * ```
80
     *
81
     * If no renderer is available for the given view file, the view file will be treated as a normal PHP
82
     * and rendered via {@see PhpTemplateRenderer}.
83
     *
84
     * @psalm-param array<string, TemplateRendererInterface> $renderers
85
     */
86 3
    public function withRenderers(array $renderers): static
87
    {
88 3
        $new = clone $this;
89 3
        $new->renderers = $renderers;
90 3
        return $new;
91
    }
92
93
    /**
94
     * Returns a new instance with the specified source locale.
95
     *
96
     * @param string $locale The source locale.
97
     */
98 3
    public function withSourceLocale(string $locale): static
99
    {
100 3
        $new = clone $this;
101 3
        $new->sourceLocale = $locale;
102 3
        return $new;
103
    }
104
105
    /**
106
     * Returns a new instance with the specified default view file extension.
107
     *
108
     * @param string $defaultExtension The default view file extension. Default is {@see ViewInterface::PHP_EXTENSION}.
109
     * This will be appended to view file names if they don't have file extensions.
110
     * @deprecated Since 8.0.1 and will be removed in the next major version. Use {@see withFallbackExtension()} instead.
111
     */
112 4
    public function withDefaultExtension(string $defaultExtension): static
113
    {
114 4
        return $this->withFallbackExtension($defaultExtension);
115
    }
116
117
    /**
118
     * Returns a new instance with the specified fallback view file extension.
119
     *
120
     * @param string $fallbackExtension The fallback view file extension. Default is {@see ViewInterface::PHP_EXTENSION}.
121
     * This will be appended to view file names if they don't exist.
122
     */
123 4
    public function withFallbackExtension(string $fallbackExtension): static
124
    {
125 4
        $new = clone $this;
126 4
        $new->fallbackExtension = $fallbackExtension;
127 4
        return $new;
128
    }
129
130
    /**
131
     * Returns a new instance with the specified view context instance.
132
     *
133
     * @param ViewContextInterface $context The context under which the {@see renderFile()} method is being invoked.
134
     */
135 5
    public function withContext(ViewContextInterface $context): static
136
    {
137 5
        $new = clone $this;
138 5
        $new->context = $context;
139 5
        $new->viewFiles = [];
140 5
        return $new;
141
    }
142
143
    /**
144
     * Returns a new instance with the specified view context path.
145
     *
146
     * @param string $path The context path under which the {@see renderFile()} method is being invoked.
147
     */
148 2
    public function withContextPath(string $path): static
149
    {
150 2
        return $this->withContext(new ViewContext($path));
151
    }
152
153
    /**
154
     * Returns a new instance with specified salt for the placeholder signature {@see getPlaceholderSignature()}.
155
     *
156
     * @param string $salt The placeholder salt.
157
     */
158 2
    public function withPlaceholderSalt(string $salt): static
159
    {
160 2
        $new = clone $this;
161 2
        $new->setPlaceholderSalt($salt);
162 2
        return $new;
163
    }
164
165
    /**
166
     * Set the specified locale code.
167
     *
168
     * @param string $locale The locale code.
169
     */
170 2
    public function setLocale(string $locale): static
171
    {
172 2
        $this->localeState->setLocale($locale);
173 2
        return $this;
174
    }
175
176
    /**
177
     * Set the specified locale code.
178
     *
179
     * @param string $locale The locale code.
180
     */
181 3
    public function withLocale(string $locale): static
182
    {
183 3
        $new = clone $this;
184 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...
185
186 3
        return $new;
187
    }
188
189
    /**
190
     * Gets the base path to the view directory.
191
     *
192
     * @return string The base view path.
193
     */
194 2
    public function getBasePath(): string
195
    {
196 2
        return $this->basePath;
197
    }
198
199
    /**
200
     * Gets the default view file extension.
201
     *
202
     * @return string The default view file extension.
203
     * @deprecated Since 8.0.1 and will be removed in the next major version. Use {@see getFallbackExtension()} instead.
204
     */
205 1
    public function getDefaultExtension(): string
206
    {
207 1
        return $this->getFallbackExtension();
208
    }
209
210
    /**
211
     * Gets the fallback view file extension.
212
     *
213
     * @return string The fallback view file extension.
214
     */
215 1
    public function getFallbackExtension(): string
216
    {
217 1
        return $this->fallbackExtension;
218
    }
219
220
    /**
221
     * Gets the theme instance, or `null` if no theme has been set.
222
     *
223
     * @return Theme|null The theme instance, or `null` if no theme has been set.
224
     */
225 60
    public function getTheme(): ?Theme
226
    {
227 60
        return $this->state->getTheme();
228
    }
229
230
    /**
231
     * Set the specified theme instance.
232
     *
233
     * @param Theme|null $theme The theme instance or `null` for reset theme.
234
     */
235 1
    public function setTheme(?Theme $theme): static
236
    {
237 1
        $this->state->setTheme($theme);
238 1
        return $this;
239
    }
240
241
    /**
242
     * Sets a common parameters that is accessible in all view templates.
243
     *
244
     * @param array $parameters Parameters that are common for all view templates.
245
     *
246
     * @psalm-param array<string, mixed> $parameters
247
     *
248
     * @see setParameter()
249
     */
250 5
    public function setParameters(array $parameters): static
251
    {
252 5
        $this->state->setParameters($parameters);
253 5
        return $this;
254
    }
255
256
    /**
257
     * Sets a common parameter that is accessible in all view templates.
258
     *
259
     * @param string $id The unique identifier of the parameter.
260
     * @param mixed $value The value of the parameter.
261
     */
262 12
    public function setParameter(string $id, mixed $value): static
263
    {
264 12
        $this->state->setParameter($id, $value);
265 12
        return $this;
266
    }
267
268
    /**
269
     * Add values to end of common array parameter. If specified parameter does not exist or him is not array,
270
     * then parameter will be added as empty array.
271
     *
272
     * @param string $id The unique identifier of the parameter.
273
     * @param mixed ...$value Value(s) for add to end of array parameter.
274
     *
275
     * @throws InvalidArgumentException When specified parameter already exists and is not an array.
276
     */
277 6
    public function addToParameter(string $id, mixed ...$value): static
278
    {
279 6
        $this->state->addToParameter($id, ...$value);
280 5
        return $this;
281
    }
282
283
    /**
284
     * Removes a common parameter.
285
     *
286
     * @param string $id The unique identifier of the parameter.
287
     */
288 3
    public function removeParameter(string $id): static
289
    {
290 3
        $this->state->removeParameter($id);
291 3
        return $this;
292
    }
293
294
    /**
295
     * Gets a common parameter value by ID.
296
     *
297
     * @param string $id The unique identifier of the parameter.
298
     * @param mixed $default The default value to be returned if the specified parameter does not exist.
299
     *
300
     * @throws InvalidArgumentException If specified parameter does not exist and not passed default value.
301
     *
302
     * @return mixed The value of the parameter.
303
     */
304 8
    public function getParameter(string $id)
305
    {
306 8
        return call_user_func_array([$this->state, 'getParameter'], func_get_args());
0 ignored issues
show
Bug introduced by
The function call_user_func_array was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

306
        return /** @scrutinizer ignore-call */ call_user_func_array([$this->state, 'getParameter'], func_get_args());
Loading history...
307
    }
308
309
    /**
310
     * Checks the existence of a common parameter by ID.
311
     *
312
     * @param string $id The unique identifier of the parameter.
313
     *
314
     * @return bool Whether a custom parameter that is common for all view templates exists.
315
     */
316 5
    public function hasParameter(string $id): bool
317
    {
318 5
        return $this->state->hasParameter($id);
319
    }
320
321
    /**
322
     * Sets a content block.
323
     *
324
     * @param string $id The unique identifier of the block.
325
     * @param string $content The content of the block.
326
     */
327 7
    public function setBlock(string $id, string $content): static
328
    {
329 7
        $this->state->setBlock($id, $content);
330 7
        return $this;
331
    }
332
333
    /**
334
     * Removes a content block.
335
     *
336
     * @param string $id The unique identifier of the block.
337
     */
338 3
    public function removeBlock(string $id): static
339
    {
340 3
        $this->state->removeBlock($id);
341 3
        return $this;
342
    }
343
344
    /**
345
     * Gets content of the block by ID.
346
     *
347
     * @param string $id The unique identifier of the block.
348
     *
349
     * @return string The content of the block.
350
     */
351 2
    public function getBlock(string $id): string
352
    {
353 2
        return $this->state->getBlock($id);
354
    }
355
356
    /**
357
     * Checks the existence of a content block by ID.
358
     *
359
     * @param string $id The unique identifier of the block.
360
     *
361
     * @return bool Whether a content block exists.
362
     */
363 5
    public function hasBlock(string $id): bool
364
    {
365 5
        return $this->state->hasBlock($id);
366
    }
367
368
    /**
369
     * Gets the view file currently being rendered.
370
     *
371
     * @return string|null The view file currently being rendered. `null` if no view file is being rendered.
372
     */
373 5
    public function getViewFile(): ?string
374
    {
375
        /** @psalm-suppress InvalidArrayOffset */
376 5
        return empty($this->viewFiles) ? null : end($this->viewFiles)['resolved'];
377
    }
378
379
    /**
380
     * Gets the placeholder signature.
381
     *
382
     * @return string The placeholder signature.
383
     */
384 49
    public function getPlaceholderSignature(): string
385
    {
386 49
        return $this->placeholderSignature;
387
    }
388
389
    /**
390
     * Renders a view.
391
     *
392
     * The view to be rendered can be specified in one of the following formats:
393
     *
394
     * - The name of the view starting with a slash to join the base path {@see getBasePath()} (e.g. "/site/index").
395
     * - The name of the view without the starting slash (e.g. "site/index"). The corresponding view file will be
396
     *   looked for under the {@see ViewContextInterface::getViewPath()} of the context set via {@see withContext()}.
397
     *   If the context instance was not set {@see withContext()}, it will be looked for under the directory containing
398
     *   the view currently being rendered (i.e., this happens when rendering a view within another view).
399
     *
400
     * @param string $view The view name.
401
     * @param array $parameters The parameters (name-value pairs) that will be extracted and made available in the view
402
     * file.
403
     *
404
     * @throws RuntimeException If the view cannot be resolved.
405
     * @throws ViewNotFoundException If the view file does not exist.
406
     * @throws Throwable
407
     *
408
     * {@see renderFile()}
409
     *
410
     * @return string The rendering result.
411
     */
412 56
    public function render(string $view, array $parameters = []): string
413
    {
414 56
        $viewFile = $this->findTemplateFile($view);
415
416 55
        return $this->renderFile($viewFile, $parameters);
417
    }
418
419
    /**
420
     * Renders a view file.
421
     *
422
     * If the theme was set {@see setTheme()}, it will try to render the themed version of the view file
423
     * as long as it's available.
424
     *
425
     * If the renderer was set {@see withRenderers()}, the method will use it to render the view file. Otherwise,
426
     * it will simply include the view file as a normal PHP file, capture its output and return it as a string.
427
     *
428
     * @param string $viewFile The full absolute path of the view file.
429
     * @param array $parameters The parameters (name-value pairs) that will be extracted and made available in the view
430
     * file.
431
     *
432
     * @throws Throwable
433
     * @throws ViewNotFoundException If the view file doesn't exist
434
     *
435
     * @return string The rendering result.
436
     */
437 60
    public function renderFile(string $viewFile, array $parameters = []): string
438
    {
439 60
        $parameters = array_merge($this->state->getParameters(), $parameters);
440
441
        // TODO: these two match now
442 60
        $requestedFile = $viewFile;
443
444 60
        $theme = $this->getTheme();
445 60
        if ($theme !== null) {
446 1
            $viewFile = $theme->applyTo($viewFile);
447
        }
448
449 60
        if (is_file($viewFile)) {
450 59
            $viewFile = $this->localize($viewFile);
451
        } else {
452 1
            throw new ViewNotFoundException("The view file \"$viewFile\" does not exist.");
453
        }
454
455 59
        $output = '';
456 59
        $this->viewFiles[] = [
457 59
            'resolved' => $viewFile,
458 59
            'requested' => $requestedFile,
459 59
        ];
460
461 59
        if ($this->beforeRender($viewFile, $parameters)) {
462 59
            $ext = pathinfo($viewFile, PATHINFO_EXTENSION);
463 59
            $renderer = $this->renderers[$ext] ?? new PhpTemplateRenderer();
464 59
            $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

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