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