Skip to content

Commit 181e55e

Browse files
committed
feat: config, catalog and translator
1 parent 1e4137b commit 181e55e

25 files changed

+698
-4
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"packages/datetime/src/functions.php",
140140
"packages/debug/src/functions.php",
141141
"packages/event-bus/src/functions.php",
142+
"packages/i18n/src/functions.php",
142143
"packages/mapper/src/functions.php",
143144
"packages/reflection/src/functions.php",
144145
"packages/router/src/functions.php",
@@ -205,7 +206,7 @@
205206
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
206207
"rector": "vendor/bin/rector process --no-ansi",
207208
"merge": "php -d\"error_reporting = E_ALL & ~E_DEPRECATED\" vendor/bin/monorepo-builder merge",
208-
"i18n:plural": "./packages/i18n/bin/plural-rules.php",
209+
"i18n:plural": "./packages/i18n/bin/plural-rules.php",
209210
"release": [
210211
"composer qa",
211212
"./bin/release"

packages/i18n/composer.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
"minimum-stability": "dev",
66
"require": {
77
"php": "^8.4",
8-
"tempest/container": "dev-main"
8+
"tempest/core": "dev-main",
9+
"tempest/container": "dev-main",
10+
"tempest/datetime": "dev-main"
911
},
1012
"require-dev": {
1113
"phpunit/phpunit": "^11.5.17"
1214
},
1315
"autoload": {
16+
"files": [
17+
"src/functions.php"
18+
],
1419
"psr-4": {
1520
"Tempest\\Internationalization\\": "src"
1621
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Tempest\Internationalization\Catalog;
4+
5+
use Tempest\Support\Language\Locale;
6+
7+
interface Catalog
8+
{
9+
/**
10+
* Determines if a translation exists for a given key in the specified locale.
11+
*/
12+
public function has(Locale $locale, string $key): bool;
13+
14+
/**
15+
* Gets the translation for a given key in the specified locale.
16+
*/
17+
public function get(Locale $locale, string $key): ?string;
18+
19+
/**
20+
* Adds a translation message for a given key in the specified locale.
21+
*/
22+
public function add(Locale $locale, string $key, string $message): self;
23+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Tempest\Internationalization\Catalog;
4+
5+
use Tempest\Container\Container;
6+
use Tempest\Container\Initializer;
7+
use Tempest\Internationalization\InternationalizationConfig;
8+
use Tempest\Support\Arr;
9+
use Tempest\Support\Filesystem;
10+
use Tempest\Support\Json;
11+
use Tempest\Support\Language\Locale;
12+
13+
final class CatalogInitializer implements Initializer
14+
{
15+
public function initialize(Container $container): Catalog
16+
{
17+
$config = $container->get(InternationalizationConfig::class);
18+
$catalog = [];
19+
20+
foreach ($config->translationMessagePaths as $locale => $paths) {
21+
$locale = Locale::from($locale)->value;
22+
$catalog[$locale] ??= [];
23+
24+
foreach ($paths as $path) {
25+
$messages = Json\decode(Filesystem\read_file($path));
26+
$messages = Arr\undot($messages);
27+
28+
foreach ($messages as $key => $message) {
29+
$catalog[$locale][$key] = $message;
30+
}
31+
}
32+
}
33+
34+
return new GenericCatalog($catalog);
35+
}
36+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Tempest\Internationalization\Catalog;
4+
5+
use Tempest\Internationalization\InternationalizationConfig;
6+
use Tempest\Support\Arr;
7+
use Tempest\Support\Language\Locale;
8+
9+
final class GenericCatalog implements Catalog
10+
{
11+
/**
12+
* @var array<string,string[]> $catalog
13+
*/
14+
public function __construct(
15+
private array $catalog = [],
16+
) {}
17+
18+
public function has(Locale $locale, string $key): bool
19+
{
20+
return Arr\has($this->catalog, "{$locale->value}.{$key}");
21+
}
22+
23+
public function get(Locale $locale, string $key): ?string
24+
{
25+
return Arr\get_by_key(
26+
array: $this->catalog,
27+
key: "{$locale->value}.{$key}",
28+
default: Arr\get_by_key(
29+
array: $this->catalog,
30+
key: "{$locale->getLanguage()}.{$key}",
31+
),
32+
);
33+
}
34+
35+
public function add(Locale $locale, string $key, string $message): self
36+
{
37+
$this->catalog = Arr\set_by_key($this->catalog, "{$locale->value}.{$key}", $message);
38+
39+
return $this;
40+
}
41+
}

packages/i18n/src/InternationalizationConfig.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,40 @@
22

33
namespace Tempest\Internationalization;
44

5+
use Tempest\Internationalization\MessageFormat\Formatter\MessageFormatFunction;
6+
use Tempest\Support\Language\Locale;
7+
58
final class InternationalizationConfig
69
{
10+
/** @var MessageFormatFunction[] */
11+
public array $functions = [];
12+
13+
/** @var array<string,string[]> */
14+
public array $translationMessagePaths = [];
15+
716
public function __construct(
8-
/** @var MessageFormatFunction[] */
9-
public array $functions = [],
17+
/**
18+
* Defines the locale used throughout the application.
19+
*/
20+
public Locale $currentLocale,
21+
22+
/**
23+
* Defines the fallback locale used when a translation message does not exist in the current locale.
24+
*/
25+
public Locale $fallbackLocale,
1026
) {}
27+
28+
public function addMessageFormatFunction(MessageFormatFunction $fn): void
29+
{
30+
$this->functions[] = $fn;
31+
}
32+
33+
public function addTranslationMessageFile(Locale $locale, string $path): void
34+
{
35+
$this->translationMessagePaths[$locale->value] ??= [];
36+
37+
if (! in_array($path, $this->translationMessagePaths[$locale->value], strict: true)) {
38+
$this->translationMessagePaths[$locale->value][] = $path;
39+
}
40+
}
1141
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Internationalization;
6+
7+
use Tempest\Container\Container;
8+
use Tempest\Discovery\Discovery;
9+
use Tempest\Discovery\DiscoveryLocation;
10+
use Tempest\Discovery\IsDiscovery;
11+
use Tempest\Internationalization\MessageFormat\Formatter\MessageFormatFunction;
12+
use Tempest\Reflection\ClassReflector;
13+
14+
final class MessageFormatFunctionDiscovery implements Discovery
15+
{
16+
use IsDiscovery;
17+
18+
public function __construct(
19+
private readonly Container $container,
20+
private readonly InternationalizationConfig $config,
21+
) {}
22+
23+
public function discover(DiscoveryLocation $location, ClassReflector $class): void
24+
{
25+
if (! $class->implements(MessageFormatFunction::class)) {
26+
return;
27+
}
28+
29+
$this->discoveryItems->add($location, $class->getName());
30+
}
31+
32+
public function apply(): void
33+
{
34+
foreach ($this->discoveryItems as $className) {
35+
$this->config->addMessageFormatFunction($this->container->get($className));
36+
}
37+
}
38+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Tempest\Internationalization;
4+
5+
use Tempest\Container\Container;
6+
use Tempest\Container\Initializer;
7+
use Tempest\Internationalization\MessageFormat\Formatter\MessageFormatter;
8+
use Tempest\Internationalization\PluralRules\PluralRulesMatcher;
9+
10+
final class MessageFormatterInitializer implements Initializer
11+
{
12+
public function initialize(Container $container): mixed
13+
{
14+
$config = $container->get(InternationalizationConfig::class);
15+
16+
return new MessageFormatter(
17+
functions: $config->functions,
18+
pluralRules: new PluralRulesMatcher(),
19+
);
20+
}
21+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Internationalization;
6+
7+
use Tempest\Discovery\DiscoversPath;
8+
use Tempest\Discovery\Discovery;
9+
use Tempest\Discovery\DiscoveryLocation;
10+
use Tempest\Discovery\IsDiscovery;
11+
use Tempest\Reflection\ClassReflector;
12+
use Tempest\Support\Language\Locale;
13+
14+
use function Tempest\Support\arr;
15+
use function Tempest\Support\str;
16+
use function Tempest\Support\Str\ends_with;
17+
18+
final class TranslationMessageDiscovery implements Discovery, DiscoversPath
19+
{
20+
use IsDiscovery;
21+
22+
public function __construct(
23+
private readonly InternationalizationConfig $config,
24+
) {}
25+
26+
public function discover(DiscoveryLocation $location, ClassReflector $class): void
27+
{
28+
return;
29+
}
30+
31+
public function discoverPath(DiscoveryLocation $location, string $path): void
32+
{
33+
if (! ends_with($path, '.json')) {
34+
return;
35+
}
36+
37+
if (! $this->isLocale($locale = str($path)->beforeLast('.')->afterLast('.')->toString())) {
38+
return;
39+
}
40+
41+
if (! is_file($path)) {
42+
return;
43+
}
44+
45+
$this->discoveryItems->add($location, [$path, $locale]);
46+
}
47+
48+
public function apply(): void
49+
{
50+
foreach ($this->discoveryItems as [$path, $locale]) {
51+
$this->config->addTranslationMessageFile(Locale::from($locale), $path);
52+
}
53+
}
54+
55+
private function isLocale(string $candidate): bool
56+
{
57+
$locale = arr(Locale::cases())
58+
->first(function (Locale $locale) use ($candidate) {
59+
if (strtolower($locale->value) === strtolower($candidate)) {
60+
return true;
61+
}
62+
63+
return strtolower($locale->getLanguage()) === strtolower($candidate);
64+
});
65+
66+
return ! is_null($locale);
67+
}
68+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace Tempest\Internationalization\Translator;
4+
5+
use Tempest\Core\ExceptionReporter;
6+
use Tempest\EventBus\EventBus;
7+
use Tempest\Internationalization\Catalog\Catalog;
8+
use Tempest\Internationalization\InternationalizationConfig;
9+
use Tempest\Internationalization\MessageFormat\Formatter\MessageFormatter;
10+
use Tempest\Support\Language\Locale;
11+
12+
final class GenericTranslator implements Translator
13+
{
14+
public function __construct(
15+
private readonly InternationalizationConfig $config,
16+
private readonly Catalog $catalog,
17+
private readonly MessageFormatter $formatter,
18+
private readonly ?EventBus $eventBus = null,
19+
) {}
20+
21+
public function translateForLocale(Locale $locale, string $key, mixed ...$arguments): string
22+
{
23+
$message = $this->catalog->get($locale, $key);
24+
25+
if (! $message) {
26+
$message = $this->catalog->get($this->config->fallbackLocale, $key);
27+
}
28+
29+
if (! $message) {
30+
$this->eventBus?->dispatch(new TranslationMiss(
31+
locale: $locale,
32+
key: $key,
33+
));
34+
35+
return $key;
36+
}
37+
38+
try {
39+
return $this->formatter->format($message, ...$arguments);
40+
} catch (\Throwable $exception) {
41+
$this->eventBus?->dispatch(new TranslationFailure(
42+
locale: $locale,
43+
key: $key,
44+
exception: $exception,
45+
));
46+
47+
return $key;
48+
}
49+
}
50+
51+
public function translate(string $key, mixed ...$arguments): string
52+
{
53+
return $this->translateForLocale($this->config->currentLocale, $key, ...$arguments);
54+
}
55+
}

0 commit comments

Comments
 (0)