Passed
Push — master ( 64bfdf...f55480 )
by Alexander
02:17
created

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