Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.14% covered (warning)
57.14%
44 / 77
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Locales
57.14% covered (warning)
57.14%
44 / 77
73.68% covered (warning)
73.68%
14 / 19
196.40
0.00% covered (danger)
0.00%
0 / 1
 load
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 catalog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 catalogs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 panelLocales
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 catalogLocales
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 add
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rewind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 current
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 next
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefault
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 negotiate
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setNegotiator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fromRequest
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
14.81
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromBrowser
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Core\App;
8use Celema\Core\Plugin as CorePlugin;
9use Closure;
10use Cosray\Exception\RuntimeException;
11use Cosray\Middleware\AddLocale;
12use Countable;
13use Iterator;
14use Override;
15use Psr\Http\Message\ServerRequestInterface as Request;
16
17/** @implements Iterator<string, Locale> */
18class Locales implements Iterator, Countable, CorePlugin
19{
20    /** @var array<string, Locale> */
21    protected array $locales = [];
22
23    /** @var array<string, string> App translation domains, mapped to their catalog dir. */
24    protected array $catalogs = [];
25
26    protected ?string $default = null;
27    protected ?Closure $negotiator = null;
28
29    #[Override]
30    public function load(App $app): void
31    {
32        $app->middleware(new AddLocale($this));
33        $app->register(self::class, $this);
34    }
35
36    /**
37     * Register a translation domain and the directory holding its
38     * `<domain>.<locale>.php` catalog files. Domains registered here are
39     * searched before Cosray's own catalogs, so an app can shadow any id.
40     */
41    public function catalog(string $domain, string $dir): void
42    {
43        $this->catalogs = [$domain => $dir] + $this->catalogs;
44    }
45
46    /**
47     * The domain cascade for the runtime translator: registered app domains
48     * first, Cosray's bundled catalogs last.
49     *
50     * @return array<string, string>
51     */
52    public function catalogs(): array
53    {
54        $dir = dirname(__DIR__) . '/lang';
55
56        return $this->catalogs + ['cosray' => $dir, 'panel' => $dir];
57    }
58
59    /**
60     * The locale ids selectable as panel UI language: those whose `cosray`
61     * and `panel` domains both ship a catalog file, in alphabetical order.
62     * Independent of the content locales registered with {@see self::add()}.
63     *
64     * @return list<string>
65     */
66    public function panelLocales(): array
67    {
68        $catalogs = $this->catalogs();
69
70        return array_values(array_intersect(
71            self::catalogLocales('cosray', $catalogs['cosray']),
72            self::catalogLocales('panel', $catalogs['panel']),
73        ));
74    }
75
76    /** @return list<string> */
77    protected static function catalogLocales(string $domain, string $dir): array
78    {
79        $ids = [];
80
81        foreach (glob($dir . '/' . $domain . '.*.php') ?: [] as $file) {
82            $id = substr(basename($file, '.php'), strlen($domain) + 1);
83
84            if (preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1) {
85                $ids[] = $id;
86            }
87        }
88
89        return $ids;
90    }
91
92    /**
93     * @param array<string>|null $domains
94     * @return void
95     */
96    public function add(
97        string $id,
98        string $title,
99        ?string $fallback = null,
100        ?string $pgDict = null,
101        ?array $domains = null,
102        ?string $urlPrefix = null,
103    ) {
104        // The first locale is always the default one
105        if ($this->default === null) {
106            $this->default = $id;
107        }
108
109        $this->locales[$id] = new Locale($this, $id, $title, $fallback, $pgDict, $domains, $urlPrefix);
110    }
111
112    public function get(string $id): Locale
113    {
114        return $this->locales[$id];
115    }
116
117    /**
118     * Counting must not move the cursor: callers ask how many locales there
119     * are from inside a `foreach` over the same object.
120     */
121    #[Override]
122    public function count(): int
123    {
124        return count($this->locales);
125    }
126
127    #[Override]
128    public function rewind(): void
129    {
130        reset($this->locales);
131    }
132
133    #[Override]
134    public function current(): Locale
135    {
136        return current($this->locales) ?: throw new RuntimeException('The locales iterator is past its end');
137    }
138
139    #[Override]
140    public function key(): string
141    {
142        return key($this->locales) ?? throw new RuntimeException('The locales iterator is past its end');
143    }
144
145    #[Override]
146    public function next(): void
147    {
148        next($this->locales);
149    }
150
151    #[Override]
152    public function valid(): bool
153    {
154        return key($this->locales) !== null;
155    }
156
157    public function getDefault(): Locale
158    {
159        // default locale from config file
160        if ($this->default === null) {
161            throw new RuntimeException('Default locale not available');
162        }
163
164        return $this->locales[$this->default];
165    }
166
167    public function negotiate(Request $request): Locale
168    {
169        if ($this->negotiator) {
170            return ($this->negotiator)($request, $this->locales, $this->getDefault());
171        }
172
173        return $this->fromRequest($request, $this->locales, $this->getDefault());
174    }
175
176    public function setNegotiator(Closure $func): void
177    {
178        $this->negotiator = $func;
179    }
180
181    /** @param array<string, Locale> $locales */
182    protected function fromRequest(Request $request, array $locales, Locale $default): Locale
183    {
184        $uri = $request->getUri();
185
186        // From query paramter
187        $locale = $request->getQueryParams()['locale'] ?? null;
188
189        if ($locale && $this->exists($locale)) {
190            return $locales[$locale];
191        }
192
193        // By domain
194        $host = strtolower(explode(':', $uri->getHost())[0]);
195
196        foreach ($locales as $locale) {
197            foreach ($locale->domains as $domain) {
198                if ($host === $domain) {
199                    return $locale;
200                }
201            }
202        }
203
204        // From URL path prefix. e. g. http://example.com/en_EN/path/to/page
205        $prefix = explode('/', trim($uri->getPath(), '/'))[0];
206
207        foreach ($locales as $locale) {
208            if ($prefix === $locale->urlPrefix) {
209                return $locale;
210            }
211        }
212
213        // From session
214        $session = $request->getAttribute('session', null);
215
216        if ($session) {
217            $locale = $session->get('locale', false);
218
219            if ($locale && $this->exists($locale)) {
220                return $locales[$locale];
221            }
222        }
223
224        // From the locales the browser says the user accepts
225        // $locale = $this->fromBrowser($request);
226        // if ($locale && $this->exists($locale)) {
227        //    return $locales[$locale];
228        // }
229
230        return $default;
231    }
232
233    public function exists(string $id): bool
234    {
235        return array_key_exists($id, $this->locales);
236    }
237
238    protected function fromBrowser(Request $request): string|false
239    {
240        $accepted = $request->getHeaderLine('Accept-Language');
241
242        if ($accepted !== '') {
243            preg_match_all(
244                '/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/i',
245                $accepted,
246                $matches,
247            );
248
249            if (count($matches[1])) {
250                $langs = array_combine($matches[1], $matches[4]);
251
252                foreach ($langs as $lang => $val) {
253                    if ($val !== '') {
254                        continue;
255                    }
256
257                    $langs[$lang] = 1;
258                }
259
260                arsort($langs, SORT_NUMERIC);
261
262                foreach ($langs as $lang => $val) {
263                    if ($this->exists($lang)) {
264                        return $lang;
265                    }
266
267                    $lang = str_replace('-', '_', $lang);
268
269                    if ($this->exists($lang)) {
270                        return $lang;
271                    }
272
273                    $lang = strtok($lang, '_');
274
275                    if ($this->exists($lang)) {
276                        return $lang;
277                    }
278                }
279            }
280        }
281
282        return false;
283    }
284}