Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.84% covered (warning)
88.84%
215 / 242
71.43% covered (warning)
71.43%
30 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bootstrap
88.84% covered (warning)
88.84%
215 / 242
71.43% covered (warning)
71.43%
30 / 42
84.02
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
3
 plugin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadPlugins
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
9
 addMigrations
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 addSql
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 addAssets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blockType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 controls
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTemplates
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 addPanelPage
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 panelExtras
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 policy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addService
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRoutes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collect
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 section
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 icons
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 navigation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldSchemas
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nodeSchemas
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fieldServices
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collectionSchemas
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 node
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 nodeClasses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localIconPaths
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 database
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createConnection
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 createDatabase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runJobs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 releaseDatabase
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 catchallRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderer
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 synchronizeNodes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 addPanelRenderer
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addViewRenderer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hasRenderer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 viewPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Container\Container;
8use Celema\Container\Entry;
9use Celema\Core\App;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Plugin as CorePlugin;
12use Celema\Quma\Connection;
13use Celema\Quma\Database;
14use Celema\Quma\Delimiters;
15use Celema\Router\Route;
16use Closure;
17use Cosray\Block\Registry as BlockRegistry;
18use Cosray\Collection\AllContent;
19use Cosray\Collection\Ref as CollectionRef;
20use Cosray\Collection\Schema\Registry as CollectionSchemaRegistry;
21use Cosray\Collection\Schemas as CollectionSchemas;
22use Cosray\Contract\Block;
23use Cosray\Exception\RuntimeException;
24use Cosray\Field\Control\Registry as Controls;
25use Cosray\Field\Index as FieldIndex;
26use Cosray\Field\Schema\Registry as FieldSchemas;
27use Cosray\Field\Services as FieldServices;
28use Cosray\Icons\Iconify;
29use Cosray\Icons\Local;
30use Cosray\Icons\Provider as IconProvider;
31use Cosray\Node\Schema\Registry as NodeSchemas;
32use Cosray\Node\Types;
33use Cosray\Panel\Dashboard as PanelDashboard;
34use Cosray\Panel\Extras as PanelExtras;
35use Cosray\Plugin\Assets as PluginAssets;
36use Cosray\Plugin\Plugin;
37use Cosray\Plugin\Registrar;
38use Cosray\Queue\Runner;
39use Cosray\Security\Policy;
40use Cosray\User\Types as UserTypes;
41use Cosray\View\Boiler\Renderer as BoilerRenderer;
42use Override;
43use PDO;
44use Psr\Log\LoggerInterface as Logger;
45use ReflectionClass;
46
47class Bootstrap implements CorePlugin
48{
49    public const string NODE_TAG = 'cosray.cms.node';
50
51    // The request's worker stays busy meanwhile, and PHP's time limit still applies.
52    private const int JOBS_AFTER_RESPONSE = 10;
53    private const float SECONDS_AFTER_RESPONSE = 10.0;
54
55    protected Factory $factory;
56    protected Container $container;
57    protected Database $db;
58    protected Connection $connection;
59    protected Routes $routes;
60    protected readonly Types $types;
61    protected readonly FieldSchemas $fieldSchemas;
62    protected readonly FieldServices $fieldServices;
63    protected readonly FieldIndex $fields;
64    protected readonly BlockRegistry $blocks;
65    protected readonly Controls $controls;
66    protected readonly CollectionSchemas $collectionSchemas;
67    protected readonly PanelDashboard $dashboardCards;
68    protected readonly Policy $policy;
69    protected readonly UserTypes $userTypes;
70
71    public PanelDashboard $dashboard {
72        get => $this->dashboardCards;
73    }
74
75    /** @var array<Entry> */
76    protected array $renderers = [];
77
78    protected readonly Navigation $navigation;
79    /** @var array<non-empty-string, class-string> */
80    protected array $nodes = [];
81
82    /** @var list<class-string<IconProvider>|IconProvider> */
83    protected array $customIconProviders = [];
84    protected bool $replaceDefaultIconProviders = false;
85
86    /** @var list<class-string<Plugin>|Plugin> */
87    protected array $plugins = [];
88
89    /** @var array<string, true> */
90    protected array $pluginIds = [];
91
92    /** @var list<non-empty-string> */
93    protected array $pluginMigrations = [];
94
95    /** @var list<non-empty-string> */
96    protected array $pluginSql = [];
97
98    /** @var list<Closure> */
99    protected array $pluginRoutes = [];
100
101    protected readonly PluginAssets $pluginAssets;
102    protected readonly PanelExtras $panelExtras;
103
104    /** @var array<string, array<string, string>> */
105    protected array $pluginTemplates = [
106        'panel' => [],
107        'view' => [],
108    ];
109
110    /** @var list<array{pattern: string, endpoint: mixed, template: non-empty-string, name: string}> */
111    protected array $panelPages = [];
112
113    public function __construct(
114        protected readonly Config $config,
115        ?Types $types = null,
116    ) {
117        $this->types = $types ?? new Types();
118        $this->fieldSchemas = FieldSchemas::withDefaults();
119        $this->blocks = BlockRegistry::withDefaults();
120        $this->controls = Controls::withDefaults();
121        $this->fieldServices = new FieldServices(
122            $this->fieldSchemas,
123            $this->types,
124            $this->blocks,
125            $this->controls,
126        );
127        $this->fields = FieldIndex::withDefaults();
128        $this->pluginAssets = new PluginAssets();
129        $this->panelExtras = new PanelExtras();
130        $this->collectionSchemas = new CollectionSchemas(CollectionSchemaRegistry::withDefaults());
131        $this->navigation = new Navigation($this->collectionSchemas);
132        // Registered first so it leads the content rail above every section.
133        $this->navigation->collection(AllContent::class);
134        $this->dashboardCards = PanelDashboard::withDefaults();
135        $this->policy = Policy::withDefaults();
136        $this->userTypes = new UserTypes();
137    }
138
139    #[Override]
140    public function load(App $app): void
141    {
142        $this->factory = $app->factory();
143        $this->container = $app->container();
144
145        $this->loadPlugins();
146
147        $this->addPanelRenderer();
148        $this->addViewRenderer();
149
150        $this->collect();
151        $this->database();
152        $app->teardown($this->releaseDatabase(...));
153
154        $this->container->add($this->container::class, $this->container);
155        $this->container->add(self::class, $this);
156        $this->container->add(Config::class, $this->config);
157        $this->container->add($this->config::class, $this->config);
158        $this->container->add(Connection::class, $this->connection);
159        $this->container->add(Database::class, $this->db);
160        $this->container->add(Factory::class, $this->factory);
161        $this->container->add(Types::class, $this->types);
162        $this->container->add(NodeSchemas::class, $this->types->registry());
163        $this->container->add(FieldSchemas::class, $this->fieldSchemas);
164        $this->container->add(FieldServices::class, $this->fieldServices);
165        $this->container->add(FieldIndex::class, $this->fields);
166        $this->container->add(BlockRegistry::class, $this->blocks);
167        $this->container->add(Controls::class, $this->controls);
168        $this->container->add(CollectionSchemas::class, $this->collectionSchemas);
169        $this->container->add(CollectionSchemaRegistry::class, $this->collectionSchemas->registry());
170        $this->container->add(PanelDashboard::class, $this->dashboardCards)->value();
171        $this->container->add(PluginAssets::class, $this->pluginAssets);
172        $this->container->add(PanelExtras::class, $this->panelExtras);
173        $this->container->add(Policy::class, $this->policy);
174        $this->container->add(UserTypes::class, $this->userTypes);
175        $users = new Users($this->db, $this->userTypes);
176        $this->container->add(Users::class, $users);
177        $this->container->add(IconProvider::class, Icons::class);
178        // A constructed instance: the argument resolver would otherwise try
179        // to build the defaulted Uid parameter and fail on its string args.
180        $this->container->add(Menus::class, new Menus($this->db));
181
182        // Services can depend on a logger whether or not the application
183        // registers one, before or after boot; logger() replaces this entry.
184        if (!$this->container->has(Logger::class)) {
185            $this->container->add(Logger::class, ErrorLog::class);
186        }
187
188        if (!$this->container->has(Webhooks::class)) {
189            $this->container->add(Webhooks::class, Webhooks::fromConfig($this->config, $this->db));
190        }
191
192        $app->teardown($this->runJobs(...));
193
194        $this->routes = new Routes(
195            $this->config,
196            $this->db,
197            $this->factory,
198            $this->policy,
199            $users,
200            $this->pluginRoutes,
201            $this->panelPages,
202            new ContainerLogger($this->container),
203        );
204        $this->routes->add($app);
205    }
206
207    /** @param class-string<Plugin>|Plugin $plugin */
208    public function plugin(string|Plugin $plugin): void
209    {
210        $this->plugins[] = $plugin;
211    }
212
213    protected function loadPlugins(): void
214    {
215        $configured = (array) $this->config->get('plugins', []);
216
217        foreach ([...$configured, ...$this->plugins] as $plugin) {
218            if (is_string($plugin)) {
219                if (!is_a($plugin, Plugin::class, true)) {
220                    throw new RuntimeException('Plugins must implement ' . Plugin::class . ": {$plugin}");
221                }
222
223                $constructor = new ReflectionClass($plugin)->getConstructor();
224
225                if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
226                    throw new RuntimeException(
227                        "Plugin {$plugin} must be constructible without arguments. Read options"
228                            . " from the app config instead â€” the Registrar's option() reads the"
229                            . " plugin's '{id}.{option}' keys â€” or register a pre-built instance:"
230                            . ' $app->plugin(new '
231                            . $plugin
232                            . '(...)).',
233                    );
234                }
235
236                $plugin = new $plugin();
237            }
238
239            if (!$plugin instanceof Plugin) {
240                throw new RuntimeException('Plugins must implement ' . Plugin::class);
241            }
242
243            $id = $plugin->id();
244
245            // Like custom element names, plugin ids need a dash: dashless
246            // names stay reserved for cosray's own config keys, so the
247            // plugin's '{id}.{option}' config namespace can never collide.
248            if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)+$/', $id)) {
249                throw new RuntimeException(
250                    "Invalid plugin id '{$id}' ("
251                        . $plugin::class
252                        . '): ids consist of'
253                        . ' lowercase letters, digits and dashes, and must contain at least'
254                        . " one dash, e.g. 'acme-shop'.",
255                );
256            }
257
258            if (isset($this->pluginIds[$id])) {
259                throw new RuntimeException("Duplicate plugin id: {$id}");
260            }
261
262            $this->pluginIds[$id] = true;
263            $plugin->register(new Registrar($this, $id, $this->config));
264        }
265    }
266
267    public function addMigrations(string $dir): void
268    {
269        if ($dir === '') {
270            throw new RuntimeException('Plugin migration directory is empty');
271        }
272
273        $this->pluginMigrations[] = $dir;
274    }
275
276    public function addSql(string $dir): void
277    {
278        if ($dir === '') {
279            throw new RuntimeException('Plugin SQL directory is empty');
280        }
281
282        $this->pluginSql[] = $dir;
283    }
284
285    public function addAssets(string $id, string $dir): void
286    {
287        $this->pluginAssets->add($id, $dir);
288    }
289
290    /** @param class-string<Block> $class */
291    public function blockType(string $class): void
292    {
293        $this->blocks->register($class);
294    }
295
296    public function controls(): Controls
297    {
298        return $this->controls;
299    }
300
301    public function addTemplates(string $namespace, string $dir, string $renderer): void
302    {
303        if (!array_key_exists($renderer, $this->pluginTemplates)) {
304            throw new RuntimeException("Unknown template renderer '{$renderer}'. Use 'panel' or 'view'.");
305        }
306
307        $this->pluginTemplates[$renderer][$namespace] = $dir;
308    }
309
310    public function addPanelPage(
311        string $pattern,
312        mixed $endpoint,
313        string $template,
314        string $name,
315    ): void {
316        if ($template === '') {
317            throw new RuntimeException("Panel page '{$name}' has no template");
318        }
319
320        $this->panelPages[] = [
321            'pattern' => $pattern,
322            'endpoint' => $endpoint,
323            'template' => $template,
324            'name' => $name,
325        ];
326    }
327
328    public function panelExtras(): PanelExtras
329    {
330        return $this->panelExtras;
331    }
332
333    public function policy(): Policy
334    {
335        return $this->policy;
336    }
337
338    /** @param class-string<User> $class */
339    public function user(string $class): void
340    {
341        $this->userTypes->add($class);
342    }
343
344    /**
345     * @param non-empty-string $key
346     * @param class-string|object $value
347     */
348    public function addService(string $key, object|string $value): Entry
349    {
350        return $this->container->add($key, $value);
351    }
352
353    /** @param Closure(App): void $routes */
354    public function addRoutes(Closure $routes): void
355    {
356        $this->pluginRoutes[] = $routes;
357    }
358
359    protected function collect(): void
360    {
361        $this->container->add(Navigation::class, $this->navigation)->value();
362
363        foreach ($this->nodes as $name => $node) {
364            $this->container
365                ->tag(self::NODE_TAG)
366                ->add($name, $node);
367        }
368
369        foreach ($this->renderers as $entry) {
370            $this->container
371                ->tag(Renderer::class)
372                ->addEntry($entry);
373        }
374
375        $providers = $this->customIconProviders;
376
377        if (!$this->replaceDefaultIconProviders) {
378            $providers[] = new Local($this->localIconPaths());
379            $providers[] = Iconify::class;
380        }
381
382        foreach ($providers as $index => $provider) {
383            $this->container
384                ->tag(IconProvider::class)
385                ->add(sprintf('icons.%d', $index), $provider);
386        }
387    }
388
389    public function section(string $name): Section
390    {
391        return $this->navigation->section($name);
392    }
393
394    /** @param class-string $class */
395    public function collection(string $class): CollectionRef
396    {
397        return $this->navigation->collection($class);
398    }
399
400    /**
401     * @param class-string<IconProvider>|IconProvider $icons
402     */
403    public function icons(string|IconProvider $icons, bool $replace = false): void
404    {
405        if (is_string($icons) && !is_a($icons, IconProvider::class, true)) {
406            throw new RuntimeException('Icon providers must implement ' . IconProvider::class);
407        }
408
409        // Icons is the composite the container resolves for the port; as a
410        // provider it would ask itself for the icon.
411        if ($icons === Icons::class || $icons instanceof Icons) {
412            throw new RuntimeException(Icons::class . ' cannot be registered as an icon provider');
413        }
414
415        if ($replace) {
416            $this->customIconProviders = [];
417            $this->replaceDefaultIconProviders = true;
418        }
419
420        array_unshift($this->customIconProviders, $icons);
421    }
422
423    public function navigation(): Navigation
424    {
425        return $this->navigation;
426    }
427
428    public function meta(): Types
429    {
430        return $this->types;
431    }
432
433    public function fieldSchemas(): FieldSchemas
434    {
435        return $this->fieldSchemas;
436    }
437
438    public function nodeSchemas(): NodeSchemas
439    {
440        return $this->types->registry();
441    }
442
443    public function fieldServices(): FieldServices
444    {
445        return $this->fieldServices;
446    }
447
448    public function fields(): FieldIndex
449    {
450        return $this->fields;
451    }
452
453    public function collectionSchemas(): CollectionSchemas
454    {
455        return $this->collectionSchemas;
456    }
457
458    /** @param class-string $class */
459    public function node(string $class): void
460    {
461        $handle = (string) $this->types->get($class, 'handle');
462
463        if ($handle === '') {
464            throw new RuntimeException('Node has no handle: ' . $class);
465        }
466
467        if (isset($this->nodes[$handle])) {
468            throw new RuntimeException('Duplicate node handle: ' . $handle);
469        }
470
471        $this->nodes[$handle] = $class;
472    }
473
474    /**
475     * Every registered node type class, in registration order.
476     *
477     * @return list<class-string>
478     */
479    public function nodeClasses(): array
480    {
481        return array_values($this->nodes);
482    }
483
484    /** @return list<string> */
485    protected function localIconPaths(): array
486    {
487        return $this->config->icons->localPaths;
488    }
489
490    protected function database(): void
491    {
492        $this->connection = $this->createConnection();
493        $this->db = $this->createDatabase($this->connection);
494    }
495
496    protected function createConnection(): Connection
497    {
498        $root = dirname(__DIR__);
499        $config = $this->config->db;
500        $sql = array_merge(
501            [$root . '/db/sql'],
502            $config->sql,
503            $this->pluginSql,
504        );
505        $migrationPaths = $config->migrations;
506
507        $namespacedMigrations = [];
508        $namespacedMigrations['install'] = [$root . '/db/migrations/install'];
509        $namespacedMigrations['default'] = array_merge(
510            $migrationPaths,
511            $this->pluginMigrations,
512            [$root . '/db/migrations/update'],
513        );
514
515        return new Connection(
516            $config->dsn ?? throw new RuntimeException('No database configured: set DATABASE_URL or db.dsn'),
517            $sql,
518        )
519            ->migrations($namespacedMigrations)
520            ->fetch(PDO::FETCH_ASSOC)
521            ->options($config->options)
522            ->placeholders(Delimiters::comments(), $config->placeholders);
523    }
524
525    protected function createDatabase(Connection $connection): Database
526    {
527        return new Database($connection);
528    }
529
530    /**
531     * Runs a few due jobs once the response is out, so that sites without
532     * cron or a queue daemon get their jobs done too. It runs after the
533     * release left the connection clean and releases it again.
534     */
535    protected function runJobs(): void
536    {
537        if (!$this->config->queue->afterResponse) {
538            return;
539        }
540
541        try {
542            Runner::scoped($this->db, new ContainerLogger($this->container), $this->container)->run(
543                jobs: self::JOBS_AFTER_RESPONSE,
544                seconds: self::SECONDS_AFTER_RESPONSE,
545            );
546        } finally {
547            $this->releaseDatabase();
548        }
549    }
550
551    /**
552     * Runs after every request. A worker keeps the connection for its next
553     * request, with a transaction the request left open rolled back; with
554     * `db.reuse` off it disconnects instead.
555     */
556    protected function releaseDatabase(): void
557    {
558        if ($this->config->db->reuse) {
559            $this->db->reset();
560
561            return;
562        }
563
564        $this->db->disconnect();
565    }
566
567    /**
568     * Catchall for node url paths.
569     *
570     * Should be the last one
571     */
572    public function catchallRoute(): Route
573    {
574        return $this->routes->catchallRoute();
575    }
576
577    /** @param non-empty-string $id */
578    public function renderer(string $id, string $class): Entry
579    {
580        if (is_a($class, Renderer::class, true)) {
581            $entry = new Entry($id, $class);
582            $this->renderers[] = $entry;
583
584            return $entry;
585        }
586
587        throw new RuntimeException('Renderers must imlement the `Cosray\\Renderer` interface');
588    }
589
590    protected function synchronizeNodes(): void
591    {
592        if (!$this->db->sys->isInitialized()->one()['value']) {
593            return;
594        }
595
596        $types = array_map(
597            static fn($record) => $record['handle'],
598            $this->db
599                ->nodes
600                ->types()
601                ->all(),
602        );
603
604        foreach ($this->nodes as $handle => $class) {
605            if (in_array($handle, $types, true)) {
606                continue;
607            }
608
609            $this->db->nodes->addType([
610                'handle' => $handle,
611            ])->run();
612        }
613    }
614
615    protected function addPanelRenderer(): void
616    {
617        $root = dirname(__DIR__);
618        $this->renderer('panel', BoilerRenderer::class)->args(
619            // The cosray dir must come first: un-namespaced lookups
620            // (e.g. the 'panel' layout) search the dirs in order.
621            dirs: ['cosray' => "{$root}/panel/views", ...$this->pluginTemplates['panel']],
622            autoescape: true,
623        );
624    }
625
626    protected function addViewRenderer(): void
627    {
628        if ($this->hasRenderer('view')) {
629            return;
630        }
631
632        $this->renderer('view', BoilerRenderer::class)->args(
633            dirs: ['app' => $this->viewPath(), ...$this->pluginTemplates['view']],
634            autoescape: true,
635        );
636    }
637
638    protected function hasRenderer(string $id): bool
639    {
640        foreach ($this->renderers as $entry) {
641            if ($entry->id === $id) {
642                return true;
643            }
644        }
645
646        return false;
647    }
648
649    protected function viewPath(): string
650    {
651        $path = $this->config->path;
652
653        return rtrim($path->root, '/') . '/' . ltrim($path->views, '/');
654    }
655}