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