Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.29% covered (warning)
86.29%
107 / 124
84.21% covered (warning)
84.21%
16 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Plugin
86.29% covered (warning)
86.29%
107 / 124
84.21% covered (warning)
84.21%
16 / 19
38.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
16 / 16
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 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
 node
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 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%
22 / 22
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%
6 / 6
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 Celemas\Container\Container;
8use Celemas\Container\Entry;
9use Celemas\Core\App;
10use Celemas\Core\Factory\Factory;
11use Celemas\Core\Plugin as CorePlugin;
12use Celemas\Core\Request;
13use Celemas\Quma\Connection;
14use Celemas\Quma\Database;
15use Celemas\Quma\Delimiters;
16use Celemas\Router\Route;
17use Cosray\Exception\RuntimeException;
18use Cosray\Icons\Iconify;
19use Cosray\Icons\Local;
20use Cosray\Node\Node;
21use Cosray\Node\Types;
22use Cosray\Panel\CollectionPage;
23use Cosray\Panel\CollectionQuery;
24use Cosray\Panel\CollectionUrls;
25use Cosray\View\Boiler\Renderer as BoilerRenderer;
26use PDO;
27
28class Plugin implements CorePlugin
29{
30    public const string NODE_TAG = 'cosray.cms.node';
31
32    protected readonly Factory $factory;
33    protected readonly Container $container;
34    protected readonly Database $db;
35    protected readonly Connection $connection;
36    protected readonly Routes $routes;
37    protected readonly Types $types;
38
39    /** @property array<Entry> */
40    protected array $renderers = [];
41
42    protected readonly Navigation $navigation;
43    protected array $nodes = [];
44
45    /** @var list<class-string<Contract\Icons>|Contract\Icons> */
46    protected array $customIconProviders = [];
47    protected bool $replaceDefaultIconProviders = false;
48
49    public function __construct(
50        protected readonly Config $config,
51        ?Types $types = null,
52    ) {
53        $this->types = $types ?? new Types();
54        $this->navigation = new Navigation();
55    }
56
57    public function load(App $app): void
58    {
59        $this->factory = $app->factory();
60        $this->container = $app->container();
61
62        $this->addPanelRenderer();
63        $this->addViewRenderer();
64
65        $this->collect();
66        $this->database();
67
68        $this->container->add($this->container::class, $this->container);
69        $this->container->add(Config::class, $this->config);
70        $this->container->add($this->config::class, $this->config);
71        $this->container->add(Connection::class, $this->connection);
72        $this->container->add(Database::class, $this->db);
73        $this->container->add(Factory::class, $this->factory);
74        $this->container->add(Types::class, $this->types);
75        $this->container->add(Contract\Icons::class, Icons::class);
76
77        $this->routes = new Routes($this->config, $this->db, $this->factory);
78        $this->routes->add($app);
79    }
80
81    protected function collect(): void
82    {
83        $this->container->add(Navigation::class, $this->navigation)->value();
84
85        foreach ($this->navigation->refs() as $name => $collection) {
86            $this->container
87                ->tag(Collection::class)
88                ->add($name, $collection::class);
89        }
90
91        foreach ($this->nodes as $name => $node) {
92            $this->container
93                ->tag(self::NODE_TAG)
94                ->add($name, $node);
95        }
96
97        foreach ($this->renderers as $entry) {
98            $this->container
99                ->tag(Renderer::class)
100                ->addEntry($entry);
101        }
102
103        $providers = $this->customIconProviders;
104
105        if (!$this->replaceDefaultIconProviders) {
106            $providers[] = new Local($this->localIconPaths());
107            $providers[] = Iconify::class;
108        }
109
110        foreach ($providers as $index => $provider) {
111            $this->container
112                ->tag(Contract\Icons::class)
113                ->add(sprintf('icons.%d', $index), $provider);
114        }
115    }
116
117    public function section(string $name): Section
118    {
119        return $this->navigation->section($name);
120    }
121
122    /** @param class-string<Collection> $class */
123    public function collection(string $class): Collection
124    {
125        return $this->navigation->collection($class);
126    }
127
128    /**
129     * @param class-string<Contract\Icons>|Contract\Icons $icons
130     */
131    public function icons(string|Contract\Icons $icons, bool $replace = false): void
132    {
133        if (is_string($icons) && !is_a($icons, Contract\Icons::class, true)) {
134            throw new RuntimeException('Icons providers must implement ' . Contract\Icons::class);
135        }
136
137        if ($replace) {
138            $this->customIconProviders = [];
139            $this->replaceDefaultIconProviders = true;
140        }
141
142        array_unshift($this->customIconProviders, $icons);
143    }
144
145    public function navigation(): Navigation
146    {
147        return $this->navigation;
148    }
149
150    public function meta(): Types
151    {
152        return $this->types;
153    }
154
155    public function node(string $class): void
156    {
157        $handle = (string) $this->types->get($class, 'handle');
158
159        if (isset($this->nodes[$handle])) {
160            throw new RuntimeException('Duplicate node handle: ' . $handle);
161        }
162
163        $this->nodes[$handle] = $class;
164    }
165
166    /** @return list<string> */
167    protected function localIconPaths(): array
168    {
169        return $this->config->icons->localPaths;
170    }
171
172    protected function database(): void
173    {
174        $root = dirname(__DIR__);
175        $config = $this->config->db;
176        $sql = array_merge(
177            [$root . '/db/sql'],
178            $config->sql,
179        );
180        $migrationPaths = $config->migrations;
181
182        $namespacedMigrations = [];
183        $namespacedMigrations['install'] = [$root . '/db/migrations/install'];
184        $namespacedMigrations['default'] = array_merge(
185            $migrationPaths,
186            [$root . '/db/migrations/update'],
187        );
188
189        $this->connection = new Connection(
190            $config->dsn,
191            $sql,
192        )
193            ->migrations($namespacedMigrations)
194            ->fetch(PDO::FETCH_ASSOC)
195            ->options($config->options)
196            ->placeholders(Delimiters::comments(), $config->placeholders);
197        $this->db = new Database($this->connection);
198    }
199
200    /**
201     * Catchall for page url paths.
202     *
203     * Should be the last one
204     */
205    public function catchallRoute(): Route
206    {
207        return $this->routes->catchallRoute();
208    }
209
210    public function renderer(string $id, string $class): Entry
211    {
212        if (is_a($class, Renderer::class, true)) {
213            $entry = new Entry($id, $class);
214            $this->renderers[] = $entry;
215
216            return $entry;
217        }
218
219        throw new RuntimeException('Renderers must imlement the `Cosray\\Renderer` interface');
220    }
221
222    protected function synchronizeNodes(): void
223    {
224        if (!$this->db->sys->isInitialized()->one()['value']) {
225            return;
226        }
227
228        $types = array_map(
229            static fn($record) => $record['handle'],
230            $this->db
231                ->nodes
232                ->types()
233                ->all(),
234        );
235
236        foreach ($this->nodes as $handle => $class) {
237            if (in_array($handle, $types, true)) {
238                continue;
239            }
240
241            $this->db->nodes->addType([
242                'handle' => $handle,
243            ])->run();
244        }
245    }
246
247    protected function addPanelRenderer(): void
248    {
249        $root = dirname(__DIR__);
250        $this->renderer('panel', BoilerRenderer::class)->args(
251            dirs: "{$root}/panel/views",
252            autoescape: true,
253            trusted: [CollectionPage::class, CollectionQuery::class, CollectionUrls::class],
254        );
255    }
256
257    protected function addViewRenderer(): void
258    {
259        if ($this->hasRenderer('view')) {
260            return;
261        }
262
263        $this->renderer('view', BoilerRenderer::class)->args(
264            dirs: $this->viewPath(),
265            autoescape: true,
266            trusted: $this->trustedViewClasses(),
267        );
268    }
269
270    protected function hasRenderer(string $id): bool
271    {
272        foreach ($this->renderers as $entry) {
273            if ($entry->id === $id) {
274                return true;
275            }
276        }
277
278        return false;
279    }
280
281    protected function viewPath(): string
282    {
283        $path = $this->config->path;
284
285        return rtrim($path->root, '/') . '/' . ltrim($path->views, '/');
286    }
287
288    /** @return list<class-string> */
289    protected function trustedViewClasses(): array
290    {
291        return [
292            Node::class,
293            Cms::class,
294            Locales::class,
295            Locale::class,
296            Config::class,
297            Request::class,
298        ];
299    }
300}