Polyglot   A
last analyzed

Complexity

Total Complexity 36

Size/Duplication

Total Lines 368
Duplicated Lines 0 %

Importance

Changes 3
Bugs 1 Features 1
Metric Value
wmc 36
eloc 80
c 3
b 1
f 1
dl 0
loc 368
rs 9.52

11 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 17 3
A phrases() 0 3 1
A constructTokenRegex() 0 10 3
B transformPhrase() 0 30 8
A has() 0 3 1
A extend() 0 9 4
A locale() 0 8 2
A replace() 0 4 1
B t() 0 24 7
A unset() 0 12 5
A clear() 0 3 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Polyglot;
6
7
use Polyglot\Pluralization\RuleFactory;
8
use Polyglot\Pluralization\Rules\RuleInterface;
9
use RuntimeException;
10
11
/**
12
 * Polyglot class based on Airbnb's Polyglot.js tiny helper.
13
 *
14
 * @package Polyglot
15
 * @author  Mihai MATEI <[email protected]>
16
 */
17
class Polyglot
18
{
19
    /**
20
     * @var array
21
     */
22
    private $phrases = [];
23
24
    /**
25
     * @var string
26
     */
27
    private $delimiter;
28
29
    /**
30
     * @var string
31
     */
32
    private $currentLocale;
33
34
    /**
35
     * @var null|callable
36
     */
37
    private $onMissingKey;
38
39
    /**
40
     * @var callable
41
     */
42
    private $warn;
43
44
    /**
45
     * @var string
46
     */
47
    private $tokenRegex;
48
49
    /**
50
     * @var array
51
     */
52
    private $customPluralRules;
53
54
    /**
55
     * @var RuleInterface
56
     */
57
    private $pluralRule;
58
59
    /**
60
     * Class constructor.
61
     *
62
     * @param array $options
63
     */
64
    public function __construct(array $options = [])
65
    {
66
        $this->extend($options['phrases'] ?? []);
67
68
        $this->customPluralRules = $options['pluralRules'] ?? [];
69
        $this->locale($options['locale'] ?? 'en');
70
71
        $this->delimiter = $options['delimiter'] ?? '||||';
72
        $this->tokenRegex = $this->constructTokenRegex($options['interpolation'] ?? []);
73
        $this->warn = $options['warn'] ?? static function() {};
74
75
        $allowMissing = ($options['allowMissing'] ?? false) === true
76
            ? static function ($key, $options, $locale, $tokenRegex, Polyglot $polyglot) {
77
                return $polyglot->transformPhrase($key, $options, $locale, $tokenRegex);
78
              }
79
            : null;
80
        $this->onMissingKey = is_callable($options['onMissingKey'] ?? false) ? $options['onMissingKey'] : $allowMissing;
81
    }
82
83
    /**
84
     * $polyglot->extend($phrases)
85
     *
86
     * Use `extend` to tell Polyglot how to translate a given key.
87
     *
88
     * $polyglot->extend([
89
     *     "hello" => "Hello",
90
     *     "hello_name" => "Hello, %{name}"
91
     * ]);
92
     *
93
     * The key can be any string. Feel free to call `extend` multiple times;
94
     * it will override any phrases with the same key, but leave existing phrases
95
     * untouched.
96
     *
97
     * It is also possible to pass nested phrase objects, which get flattened
98
     * into an object with the nested keys concatenated using dot notation.
99
     *
100
     * $polyglot->extend([
101
     *     "nav" => [
102
     *          "hello" => "Hello",
103
     *          "hello_name" => "Hello, %{name}",
104
     *          "sidebar" => [
105
     *              "welcome" => "Welcome",
106
     *          ]
107
     *      ]
108
     * ]);
109
     *
110
     * var_dump($polyglot->phrases());
111
     *
112
     *   // [
113
     *   //   'nav.hello' => 'Hello',
114
     *   //   'nav.hello_name' => 'Hello, %{name}',
115
     *   //   'nav.sidebar.welcome' => 'Welcome'
116
     *   // ]
117
     *
118
     * `extend` accepts an optional second argument, `prefix`, which can be used
119
     * to prefix every key in the phrases object with some string, using dot
120
     * notation.
121
     *
122
     * $polyglot->extend([
123
     *     "hello" => "Hello",
124
     *     "hello_name" => "Hello, %{name}"
125
     * ], "nav");
126
     *
127
     * var_dump($polyglot->phrases());
128
     *
129
     *   // [
130
     *   //   'nav.hello' => 'Hello',
131
     *   //   'nav.hello_name' => 'Hello, %{name}'
132
     *   // ]
133
     *
134
     * @param array       $morePhrases
135
     * @param string|null $prefix
136
     */
137
    public function extend(array $morePhrases = [], ?string $prefix = null): void
138
    {
139
        foreach ($morePhrases as $key => $phrase) {
140
            $prefixedKey = $prefix !== null ? $prefix . '.' . $key : $key;
141
142
            if (is_array($phrase)) {
143
                $this->extend($phrase, $prefixedKey);
144
            } else {
145
                $this->phrases[$prefixedKey] = $phrase;
146
            }
147
        }
148
    }
149
150
    /**
151
     * $polyglot->unset($phrases)
152
     *
153
     * Use `unset` to selectively remove keys from a polyglot instance.
154
     *
155
     * $polyglot->unset("some_key");
156
     * $polyglot->unset([
157
     *     "hello" => "Hello",
158
     *     "hello_name" => "Hello, %{name}"
159
     * ]);
160
     *
161
     * The unset method can take either a string (for the key), or an array with
162
     * the keys that you would like to unset.
163
     *
164
     * @param string|array $morePhrases
165
     * @param string|null  $prefix
166
     */
167
    public function unset($morePhrases, ?string $prefix = null): void
168
    {
169
        if (is_string($morePhrases)) {
170
            unset($this->phrases[$morePhrases]);
171
        } else {
172
            foreach ($morePhrases as $key => $phrase) {
173
                $prefixedKey = $prefix !== null ? $prefix . '.' . $key : $key;
174
175
                if (is_array($phrase)) {
176
                    $this->unset($phrase, $prefixedKey);
177
                } else {
178
                    unset($this->phrases[$prefixedKey]);
179
                }
180
            }
181
        }
182
183
    }
184
185
    /**
186
     * $polyglot->clear()
187
     *
188
     * Clears all phrases. Useful for special cases, such as freeing
189
     * up memory if you have lots of phrases but no longer need to
190
     * perform any translation. Also used internally by `replace`.
191
     */
192
    public function clear(): void
193
    {
194
        $this->phrases = [];
195
    }
196
197
    /**
198
     * $polyglot->replace($phrases)
199
     *
200
     * Completely replace the existing phrases with a new set of phrases.
201
     * Normally, just use `extend` to add more phrases, but under certain
202
     * circumstances, you may want to make sure no old phrases are lying around.
203
     *
204
     * @param array       $newPhrases
205
     * @param string|null $prefix
206
     */
207
    public function replace(array $newPhrases, ?string $prefix = null): void
208
    {
209
        $this->clear();
210
        $this->extend($newPhrases, $prefix);
211
    }
212
213
    /**
214
     * $polyglot->locale(?$locale)
215
     *
216
     * Get or set locale. Internally, Polyglot only uses locale for pluralization.
217
     *
218
     * @param string|null $locale
219
     *
220
     * @return string
221
     */
222
    public function locale(?string $locale = null): string
223
    {
224
        if ($locale !== null) {
225
            $this->currentLocale = $locale;
226
            $this->pluralRule = RuleFactory::make($locale, $this->customPluralRules);
227
        }
228
229
        return $this->currentLocale;
230
    }
231
232
    /**
233
     * $polyglot->has($key)
234
     *
235
     * Check if polyglot has a translation for given key
236
     *
237
     * @param string $key
238
     *
239
     * @return bool
240
     */
241
    public function has(string $key): bool
242
    {
243
        return array_key_exists($key, $this->phrases);
244
    }
245
246
    public function phrases(): array
247
    {
248
        return $this->phrases;
249
    }
250
251
    /**
252
     * $polyglot->t($key, $options)
253
     *
254
     * The most-used method. Provide a key, and `t` will return the phrase.
255
     *
256
     * $polyglot->t("hello");
257
     * => "Hello"
258
     *
259
     * The phrase value is provided first by a call to `$polyglot->extend($phrases)` or `$polyglot->replace($phrases)`.
260
     *
261
     * Pass in an object as the second argument to perform interpolation.
262
     *
263
     * $polyglot->t("hello_name", ["name" => "Spike"]);
264
     * => "Hello, Spike"
265
     *
266
     * If you like, you can provide a default value in case the phrase is missing.
267
     * Use the special option key "_" to specify a default.
268
     *
269
     * $polyglot->t("i_like_to_write_in_language", [
270
     *     '_' => "I like to write in %{language}.",
271
     *     'language' => "JavaScript"
272
     * ]);
273
     * => "I like to write in JavaScript."
274
     *
275
     * @param string     $key
276
     * @param array|int  $options
277
     *
278
     * @return string
279
     */
280
    public function t(string $key, $options = []): string
281
    {
282
        $phrase = '';
283
        $result = '';
284
        $options = is_int($options) ? ['smart_count' => $options] : $options;
285
286
        if ($this->has($key) && is_string($this->phrases[$key])) {
287
            $phrase = $this->phrases[$key];
288
        } elseif(isset($options['_'])) {
289
            $phrase = $options['_'];
290
        } elseif ($this->onMissingKey !== null) {
291
            $result = call_user_func($this->onMissingKey, $key, $options, $this->currentLocale, $this->tokenRegex, $this);
292
        } else {
293
            // warn missing translations
294
            call_user_func($this->warn, 'Missing translation for key: "' . $key . '"');
295
296
            $result = $key;
297
        }
298
299
        if (!empty($phrase)) {
300
            $result = $this->transformPhrase($phrase, $options, $this->currentLocale, $this->tokenRegex);
301
        }
302
303
        return $result;
304
    }
305
306
    /**
307
     * $polyglot->transformPhrase($phrase, $substitutions, $locale)
308
     *
309
     * Takes a phrase string and transforms it by choosing the correct plural form and interpolating it.
310
     *
311
     * $polyglot->transformPhrase('Hello, %{name}!', ['name' => 'Spike']);
312
     * => "Hello, Spike!"
313
     *
314
     * The correct plural form is selected if substitutions['smart_count'] is set. You can pass in a number instead
315
     * of an array as `$substitutions` as a shortcut for `smart_count`.
316
     *
317
     * $polyglot->transformPhrase('%{smart_count} new messages |||| 1 new message', ['smart_count' => 1], 'en');
318
     * => "1 new message"
319
     *
320
     * $polyglot->transformPhrase('%{smart_count} new messages |||| 1 new message', ['smart_count' => 2], 'en');
321
     * => "2 new messages"
322
     *
323
     * $polyglot->transformPhrase('%{smart_count} new messages |||| 1 new message', ['smart_count' => 5], 'en');
324
     * => "5 new messages"
325
     *
326
     * You should pass in a third argument, the locale, to specify the correct plural type.
327
     * It defaults to `'en'` with 2 plural forms.
328
     *
329
     * @param string         $phrase
330
     * @param null|array|int $substitutions
331
     * @param string         $locale
332
     * @param string|null    $tokenRegex
333
     *
334
     * @return string
335
     */
336
    public function transformPhrase(string $phrase, $substitutions = null, string $locale = 'en', ?string $tokenRegex = null): string
337
    {
338
        if ($substitutions === null) {
339
            return $phrase;
340
        }
341
342
        $result = $phrase;
343
344
        // allow number as a pluralization shortcut
345
        $options = is_int($substitutions) ? ['smart_count' => $substitutions] : $substitutions;
346
347
        // Select plural form: based on a phrase text that contains `n`
348
        // plural forms separated by `delimiter`, a `locale`, and a `substitutions.smart_count`,
349
        // choose the correct plural form. This is only done if `count` is set.
350
        if (($options['smart_count'] ?? null) !== null && $result) {
351
            $texts = explode($this->delimiter, $result);
352
            $pluralRule = $locale !== $this->currentLocale ? RuleFactory::make($locale, $this->customPluralRules) : $this->pluralRule;
353
            $pluralTypeIndex = $pluralRule->decide($options['smart_count']);
354
            $result = trim($texts[$pluralTypeIndex] ?? $texts[0]);
355
        }
356
357
        $interpolationRegex = $tokenRegex ?? $this->tokenRegex;
358
359
        return preg_replace_callback($interpolationRegex, static function($matches) use ($options) {
360
            if (array_key_exists($matches[1], $options) && $options[$matches[1]] !== null) {
361
                return $options[$matches[1]];
362
            }
363
364
            return ($options['interpolation']['prefix'] ?? '%{') . $matches[1] . ($options['interpolation']['suffix'] ?? '}');
365
        }, $result);
366
    }
367
368
    /**
369
     * Construct a new token regex.
370
     *
371
     * @param array $options
372
     *
373
     * @return string
374
     */
375
    private function constructTokenRegex(array $options = []): string
376
    {
377
        $prefix = $options['prefix'] ?? '%{';
378
        $suffix = $options['suffix'] ?? '}';
379
380
        if ($prefix === $this->delimiter || $suffix === $this->delimiter) {
381
            throw new RuntimeException('"' . $this->delimiter . '" token is reserved for pluralization');
382
        }
383
384
        return '~' . preg_quote($prefix) . '(.*?)' . preg_quote($suffix) . '~';
385
    }
386
}