Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
66 / 68
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PanelLocale
97.06% covered (success)
97.06%
66 / 68
66.67% covered (warning)
66.67%
4 / 6
26
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
 process
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 negotiate
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 fallback
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 fromBrowser
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 acceptedLanguages
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Middleware;
6
7use Celema\Verba\Translator;
8use Celema\Verba\Verba;
9use Cosray\Config;
10use Cosray\Locales;
11use Cosray\User;
12use Psr\Http\Message\ResponseInterface as Response;
13use Psr\Http\Message\ServerRequestInterface as Request;
14use Psr\Http\Server\MiddlewareInterface as Middleware;
15use Psr\Http\Server\RequestHandlerInterface as Handler;
16
17/**
18 * Negotiates the panel UI language independently of the content locale:
19 * the user's stored preference, then config `panel.locale`, then the
20 * browser's Accept-Language, then English. Activates a translator for the
21 * chosen locale with the remaining panel locales as fallback chain and
22 * restores the content translator afterwards. The content locale
23 * attributes stay untouched, so editing and serialization are unaffected.
24 */
25class PanelLocale implements Middleware
26{
27    public function __construct(
28        protected Config $config,
29    ) {}
30
31    public function process(Request $request, Handler $handler): Response
32    {
33        $locales = $request->getAttribute('locales', null);
34
35        if (!$locales instanceof Locales) {
36            return $handler->handle($request);
37        }
38
39        $available = $locales->panelLocales();
40
41        if ($available === []) {
42            return $handler->handle($request);
43        }
44
45        $id = $this->negotiate($request, $available);
46        $translator = new Translator($id, $locales->catalogs(), $this->fallback($id, $available));
47        $previous = Verba::translator();
48        Verba::activate($translator);
49
50        try {
51            return $handler->handle(
52                $request
53                    ->withAttribute('panelLocale', $id)
54                    ->withAttribute('panelLocales', $available)
55                    ->withAttribute('translator', $translator),
56            );
57        } finally {
58            if ($previous !== null) {
59                Verba::activate($previous);
60            } else {
61                Verba::deactivate();
62            }
63        }
64    }
65
66    /** @param non-empty-list<string> $available */
67    protected function negotiate(Request $request, array $available): string
68    {
69        $user = $request->getAttribute('user', null);
70
71        if (
72            $user instanceof User
73            && $user->panelLocale !== null
74            && in_array($user->panelLocale, $available, true)
75        ) {
76            return $user->panelLocale;
77        }
78
79        $configured = $this->config->panel->locale;
80
81        if ($configured !== null && in_array($configured, $available, true)) {
82            return $configured;
83        }
84
85        return (
86            $this->fromBrowser($request, $available)
87                ?? (in_array('en', $available, true) ? 'en' : $available[0])
88        );
89    }
90
91    /**
92     * The locales tried per string when the negotiated one lacks a
93     * translation: config default first, then English, then the rest.
94     *
95     * @param non-empty-list<string> $available
96     * @return list<string>
97     */
98    protected function fallback(string $id, array $available): array
99    {
100        $chain = array_unique([$this->config->panel->locale ?? 'en', 'en', ...$available]);
101
102        return array_values(array_filter(
103            $chain,
104            static fn(string $locale) => $locale !== $id && in_array($locale, $available, true),
105        ));
106    }
107
108    /** @param non-empty-list<string> $available */
109    protected function fromBrowser(Request $request, array $available): ?string
110    {
111        $lookup = [];
112
113        foreach ($available as $id) {
114            $lookup[strtolower(str_replace('_', '-', $id))] = $id;
115        }
116
117        foreach ($this->acceptedLanguages($request) as $tag) {
118            $tag = strtolower($tag);
119            $primary = explode('-', $tag)[0];
120
121            if (isset($lookup[$tag])) {
122                return $lookup[$tag];
123            }
124
125            if (isset($lookup[$primary])) {
126                return $lookup[$primary];
127            }
128        }
129
130        return null;
131    }
132
133    /** @return list<string> Language tags in descending quality order. */
134    protected function acceptedLanguages(Request $request): array
135    {
136        $accepted = [];
137        $position = 0;
138
139        foreach (explode(',', $request->getHeaderLine('Accept-Language')) as $part) {
140            $params = explode(';', trim($part));
141            $tag = trim($params[0]);
142
143            if (preg_match('/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/', $tag) !== 1) {
144                continue;
145            }
146
147            $quality = 1.0;
148
149            foreach (array_slice($params, 1) as $param) {
150                if (preg_match('/^\s*q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/', $param, $m) !== 1) {
151                    continue;
152                }
153
154                $quality = (float) $m[1];
155            }
156
157            if ($quality > 0) {
158                $accepted[] = ['tag' => $tag, 'quality' => $quality, 'position' => $position];
159            }
160
161            $position++;
162        }
163
164        usort(
165            $accepted,
166            static fn(array $a, array $b) => $b['quality'] <=> $a['quality'] ?: $a['position'] <=> $b['position'],
167        );
168
169        return array_column($accepted, 'tag');
170    }
171}