Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.60% covered (warning)
87.60%
106 / 121
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Panel
87.60% covered (warning)
87.60%
106 / 121
77.27% covered (warning)
77.27%
17 / 22
70.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 context
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 layer
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 messages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 panelPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localeId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 panelLocales
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 stylesheets
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 scripts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 moduleScripts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 extras
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasPanelStatic
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 panelAssetsDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 panelDev
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 panelDevOrigin
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
18.96
 panelDevHost
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 logo
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 collections
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 firstUrl
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 menusUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 renderIcon
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Container\Container;
8use Celema\Core\Request;
9use Celema\Verba\Verba;
10use Cosray\Config;
11use Cosray\Icons\Provider as IconProvider;
12use Cosray\Locale;
13use Cosray\Navigation;
14use Cosray\NavigationItem;
15use Cosray\NavLink;
16use Cosray\Panel\Extras;
17use Cosray\User;
18use Cosray\Util\Form;
19
20use function Cosray\env;
21
22abstract class Panel
23{
24    /**
25     * Which masthead area this screen belongs to. The rail renders only for
26     * `content`, which is the default because a project's own panel pages put
27     * their entry in that rail without knowing this constant exists.
28     */
29    protected const string AREA = 'content';
30
31    protected string $panelDir;
32
33    public function __construct(
34        protected Config $config,
35        protected Container $container,
36        protected readonly Request $request,
37    ) {
38        $this->panelDir = __DIR__ . '/../../../panel';
39    }
40
41    protected function context(array $data = []): array
42    {
43        $panelPath = $this->panelPath();
44        $localeId = $this->localeId();
45        $collections = $this->collections();
46
47        return array_merge([
48            'debug' => $this->config->debug(),
49            'env' => $this->config->env(),
50            'layer' => $this->layer(),
51            'panelPath' => $panelPath,
52            'panelBase' => $panelPath === '/' ? '/' : rtrim($panelPath, '/') . '/',
53            'currentPath' => $this->request->uri()->getPath(),
54            'area' => static::AREA,
55            'contentUrl' => $this->firstUrl($collections),
56            'menusUrl' => $this->menusUrl($panelPath),
57            'logo' => $this->logo(),
58            'localeId' => $localeId,
59            'panelLocales' => $this->panelLocales(),
60            'config' => $this->config,
61            'renderIcon' => $this->renderIcon(...),
62            'stylesheets' => $this->stylesheets($panelPath),
63            'scripts' => $this->scripts($panelPath),
64            'moduleScripts' => $this->moduleScripts($panelPath),
65            'collections' => $collections,
66            'rail' => static::AREA === 'content' && $collections !== [],
67            'messages' => $this->messages(),
68        ], $data);
69    }
70
71    /**
72     * How much of the panel a response renders. htmx names the element it is
73     * about to swap, and that boundary is where the layer templates stop: the
74     * content region for navigation inside an area, the frame for an area
75     * switch, the whole body for a history restore, the document otherwise.
76     */
77    protected function layer(): string
78    {
79        // A restore swaps the body. It has to come first: htmx sends this header
80        // alone, without the ones every other request carries.
81        if ($this->request->hasHeader('HX-History-Restore-Request')) {
82            return 'shell';
83        }
84
85        if (!$this->request->hasHeader('HX-Request')) {
86            return 'document';
87        }
88
89        // Anything else aimed at the body, which htmx flags as a full render.
90        if ($this->request->header('HX-Request-Type') === 'full') {
91            return 'shell';
92        }
93
94        // The target reads `<tag>#<id>`; only the id names a panel region.
95        $target = $this->request->header('HX-Target');
96        $hash = strrpos($target, '#');
97
98        return $hash !== false && substr($target, $hash + 1) === 'frame' ? 'frame' : 'main';
99    }
100
101    /**
102     * The `panel` catalog for the active locale as the payload the panel's
103     * verba runtime boots from. This domain holds exactly the strings the
104     * Svelte panel uses (extracted by the JavascriptScanner), so the browser
105     * never receives backend-only messages. Empty when no translator is
106     * active (e.g. outside the request pipeline).
107     *
108     * @return array{locale: string, domains: list<array{domain: string, plural: string, messages: array<string, string|list<string>>}>}
109     */
110    protected function messages(): array
111    {
112        return Verba::translator()?->exportMany(['panel']) ?? ['locale' => $this->localeId(), 'domains' => []];
113    }
114
115    protected function panelPath(): string
116    {
117        return $this->config->panel->path;
118    }
119
120    /**
121     * Submitted form data with fallbacks for request pipelines that do
122     * not populate the parsed body (JSON and urlencoded raw bodies).
123     */
124    protected function formData(): array
125    {
126        return Form::body($this->request);
127    }
128
129    protected function localeId(): string
130    {
131        $panelLocale = $this->request->get('panelLocale', null);
132
133        if (is_string($panelLocale)) {
134            return $panelLocale;
135        }
136
137        $locale = $this->request->get('locale', null);
138
139        return $locale instanceof Locale ? $locale->id : 'en';
140    }
141
142    /**
143     * The selectable panel UI languages, mapped to their native names
144     * (e.g. `['de' => 'Deutsch', 'en' => 'English']`).
145     *
146     * @return array<string, string>
147     */
148    protected function panelLocales(): array
149    {
150        $ids = $this->request->get('panelLocales', []);
151        $titles = [];
152
153        /** @var string $id */
154        foreach (is_array($ids) ? $ids : [] as $id) {
155            $title = \Locale::getDisplayLanguage($id, $id);
156            $titles[$id] = $title === $id ? $id : $title;
157        }
158
159        return $titles;
160    }
161
162    private function stylesheets(string $panelPath): array
163    {
164        $stylesheets = $this->config->panel->theme;
165
166        if (!$this->panelDev() && $this->hasPanelStatic()) {
167            $stylesheets[] = "{$panelPath}/static/panel.css";
168        }
169
170        return [...$stylesheets, ...$this->extras()->css()];
171    }
172
173    private function scripts(string $panelPath): array
174    {
175        if ($this->panelDev()) {
176            $origin = $this->panelDevOrigin();
177            $scripts = ["{$origin}/node_modules/htmx.org/dist/htmx.min.js"];
178        } else {
179            $scripts = $this->hasPanelStatic() ? ["{$panelPath}/static/htmx.js"] : [];
180        }
181
182        return [...$scripts, ...$this->extras()->scripts()];
183    }
184
185    private function moduleScripts(string $panelPath): array
186    {
187        if ($this->panelDev()) {
188            $origin = $this->panelDevOrigin();
189
190            return [
191                "{$origin}/@vite/client",
192                "{$origin}/src/panel.ts",
193                ...$this->extras()->moduleScripts(),
194            ];
195        }
196
197        $scripts = $this->hasPanelStatic() ? ["{$panelPath}/static/panel.js"] : [];
198
199        return [...$scripts, ...$this->extras()->moduleScripts()];
200    }
201
202    private function extras(): Extras
203    {
204        $extras = $this->container->get(Extras::class);
205        assert($extras instanceof Extras, 'The panel extras service must be available');
206
207        return $extras;
208    }
209
210    protected function hasPanelStatic(): bool
211    {
212        $static = $this->panelAssetsDir();
213
214        return (
215            is_file($static . '/panel.js')
216                && is_file($static . '/panel.css')
217                && is_file($static . '/htmx.js')
218        );
219    }
220
221    protected function panelAssetsDir(): string
222    {
223        return rtrim($this->config->path->panelAssets, '/\\');
224    }
225
226    private function panelDev(): bool
227    {
228        return filter_var(env('COSRAY_PANEL_DEV', false), FILTER_VALIDATE_BOOL);
229    }
230
231    private function panelDevOrigin(): string
232    {
233        $origin = env('COSRAY_PANEL_DEV_ORIGIN', null);
234
235        if (is_string($origin) && trim($origin) !== '') {
236            return rtrim(trim($origin), '/');
237        }
238
239        $scheme = env('COSRAY_PANEL_DEV_SCHEME', 'http');
240        $scheme = is_string($scheme) && in_array($scheme, ['http', 'https'], true) ? $scheme : 'http';
241        $port = env('COSRAY_PANEL_DEV_PORT', '2001');
242        $port = is_scalar($port) && preg_match('/^[0-9]+$/', (string) $port) ? (string) $port : '2001';
243
244        return "{$scheme}://{$this->panelDevHost()}:{$port}";
245    }
246
247    private function panelDevHost(): string
248    {
249        $host = $this->request->uri()->getHost();
250
251        if ($host === '') {
252            $host = $this->request->header('Host');
253        }
254
255        $host = trim(explode(':', $host)[0] ?? '');
256
257        return preg_match('/^[A-Za-z0-9.-]+$/', $host) === 1 ? $host : 'localhost';
258    }
259
260    private function logo(): ?string
261    {
262        $logo = $this->config->panel->logo;
263
264        if ($logo === null) {
265            return null;
266        }
267
268        $logo = trim((string) $logo);
269
270        return $logo === '' ? null : $logo;
271    }
272
273    protected function collections(): array
274    {
275        /** @var Navigation $navigation */
276        $navigation = $this->container->get(Navigation::class);
277
278        return $navigation->items();
279    }
280
281    /**
282     * Where the masthead's content entry goes: the first entry of the rail, in
283     * the rail's own order. Null when a project defines no collections.
284     *
285     * @param list<NavigationItem> $items
286     */
287    private function firstUrl(array $items): ?string
288    {
289        foreach ($items as $item) {
290            if ($item instanceof NavLink) {
291                return $item->url;
292            }
293
294            $slug = $item->slug();
295
296            if ($slug !== null) {
297                return $this->panelPath() . '/collection/' . $slug;
298            }
299
300            $url = $this->firstUrl($item->children());
301
302            if ($url !== null) {
303                return $url;
304            }
305        }
306
307        return null;
308    }
309
310    /**
311     * The masthead's menus entry, null for users without the permission.
312     * Cosmetic gating only — the menu routes enforce `edit-menus`
313     * themselves through the permission middleware.
314     */
315    private function menusUrl(string $panelPath): ?string
316    {
317        $user = $this->request->get('user', null);
318
319        if ($user instanceof User && $user->hasPermission('edit-menus')) {
320            return $panelPath . '/menus';
321        }
322
323        return null;
324    }
325
326    /** @param array{id: string, args?: array<array-key, mixed>}|null $icon */
327    private function renderIcon(?array $icon): string
328    {
329        if ($icon === null) {
330            return '';
331        }
332
333        $id = $icon['id'] ?? null;
334
335        if (!is_string($id) || trim($id) === '') {
336            return '';
337        }
338
339        $service = $this->container->get(IconProvider::class);
340
341        if (!$service instanceof IconProvider) {
342            return '';
343        }
344
345        $args = $icon['args'] ?? [];
346
347        return $service->icon(trim($id), is_array($args) ? $args : []);
348    }
349}