Skip to content

Commit 9fcce9a

Browse files
committed
Add TransModifier to support localized template interpolation
Another need we had in Respect\Validation was the ability to translate parameters into a different language, and I wanted to port that functionality to this library as well. I’ve ported the `TransModifier` from Validation, allowing us to bridge the gap between template interpolation and localization. Instead of reinventing the wheel, I’ve integrated this modifier with `symfony/translation`. By relying on the TranslatorInterface, we ensure robust translation ecosystems without being locked into a specific implementation. Assisted-by: OpenCode (GLM-4.6) Assisted-by: Gemini 3 (Thinking)
1 parent 2cae7ba commit 9fcce9a

12 files changed

Lines changed: 442 additions & 7 deletions

composer.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
"require": {
66
"symfony/polyfill-mbstring": "^1.33",
77
"php": "^8.5",
8-
"respect/stringifier": "^3.0"
8+
"respect/stringifier": "^3.0",
9+
"symfony/translation-contracts": "^3.6"
10+
},
11+
"suggest": {
12+
"symfony/translation": "For translation support in TransModifier (^6.0|^7.0)"
913
},
1014
"require-dev": {
1115
"phpunit/phpunit": "^12.5",
1216
"phpstan/phpstan": "^2.1",
1317
"phpstan/extension-installer": "^1.4",
1418
"phpstan/phpstan-deprecation-rules": "^2.0",
1519
"phpstan/phpstan-phpunit": "^2.0",
16-
"respect/coding-standard": "^5.0"
20+
"respect/coding-standard": "^5.0",
21+
"symfony/translation": "^6.0|^7.0"
1722
},
1823
"license": "ISC",
1924
"autoload": {

docs/modifiers/ListModifier.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ echo $formatter->format('Choose {{fruits|list:or}}');
4141
| `['items' => ['a', 'b']]` | `{{items\|list:or}}` | `a or b` |
4242
| `['items' => ['a', 'b', 'c']]` | `{{items\|list:and}}` | `a, b, and c` |
4343
| `['items' => ['a', 'b', 'c']]` | `{{items\|list:or}}` | `a, b, or c` |
44+
45+
## Known Limitations
46+
47+
This modifier uses the Oxford comma (a comma before the conjunction) for lists with 3+ items. This grammar rule is specific to English and may not work well with other languages.

docs/modifiers/Modifiers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ If no modifier is provided, the formatter uses `StringifyModifier` by default.
5252
- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions
5353
- **[QuoteModifier](QuoteModifier.md)** - Quotes string values using a stringifier quoter
5454
- **[StringifyModifier](StringifyModifier.md)** - Converts values to strings (default)
55+
- **[TransModifier](TransModifier.md)** - Translates string values using a Symfony translator
5556

5657
## Creating Custom Modifiers
5758

docs/modifiers/TransModifier.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# TransModifier
2+
3+
The `|trans` modifier translates string values using a `TranslatorInterface` implementation.
4+
5+
## Behavior
6+
7+
- String values with `|trans` pipe are passed through the translator
8+
- Non-string values delegate to the next modifier
9+
- Missing translations return the original key unchanged
10+
11+
## Usage
12+
13+
By default, uses a `BypassTranslator` that returns the original input:
14+
15+
```php
16+
use Respect\StringFormatter\PlaceholderFormatter;
17+
18+
$formatter = new PlaceholderFormatter(['message' => 'hello']);
19+
20+
echo $formatter->format('{{message|trans}}');
21+
// Output: hello
22+
```
23+
24+
## With Symfony Translator
25+
26+
Install `symfony/translation` and inject a real translator:
27+
28+
```php
29+
use Respect\StringFormatter\PlaceholderFormatter;
30+
use Respect\StringFormatter\Modifiers\TransModifier;
31+
use Respect\StringFormatter\Modifiers\StringifyModifier;
32+
use Symfony\Component\Translation\Translator;
33+
use Symfony\Component\Translation\Loader\ArrayLoader;
34+
35+
$translator = new Translator('en');
36+
$translator->addLoader('array', new ArrayLoader());
37+
$translator->addResource('array', ['greeting' => 'Hello World'], 'en');
38+
39+
$formatter = new PlaceholderFormatter(
40+
['key' => 'greeting'],
41+
new TransModifier(new StringifyModifier(), $translator),
42+
);
43+
44+
echo $formatter->format('{{key|trans}}');
45+
// Output: Hello World
46+
```
47+
48+
## Examples
49+
50+
| Parameters | Template | Output |
51+
| ----------------------- | ---------------- | ------------- |
52+
| `['msg' => 'hello']` | `{{msg\|trans}}` | `hello` |
53+
| `['key' => 'greeting']` | `{{key\|trans}}` | `Hello World` |

src/BypassTranslator.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter;
6+
7+
use Symfony\Contracts\Translation\TranslatorInterface;
8+
9+
/**
10+
* Bypass translator that always returns the original input.
11+
*
12+
* This implementation works regardless of whether symfony/translation
13+
* is installed, providing a fallback.
14+
*/
15+
final class BypassTranslator implements TranslatorInterface
16+
{
17+
/** @param array<string, mixed> $parameters */
18+
public function trans(
19+
string $id,
20+
array $parameters = [],
21+
string|null $domain = null,
22+
string|null $locale = null,
23+
): string {
24+
return $id;
25+
}
26+
27+
public function getLocale(): string
28+
{
29+
return 'en';
30+
}
31+
}

src/Modifiers/ListModifier.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace Respect\StringFormatter\Modifiers;
66

7+
use Respect\StringFormatter\BypassTranslator;
78
use Respect\StringFormatter\Modifier;
9+
use Symfony\Contracts\Translation\TranslatorInterface;
810

911
use function array_map;
1012
use function array_pop;
@@ -19,6 +21,7 @@
1921

2022
public function __construct(
2123
private Modifier $nextModifier,
24+
private TranslatorInterface $translator = new BypassTranslator(),
2225
) {
2326
}
2427

@@ -34,17 +37,17 @@ public function modify(mixed $value, string|null $pipe): string
3437

3538
$modifiedValues = array_map(fn($item) => $this->nextModifier->modify($item, null), $value);
3639

37-
$glue = match ($pipe) {
40+
$conjunction = $this->translator->trans(match ($pipe) {
3841
'list:and', 'list' => 'and',
3942
'list:or' => 'or',
40-
};
43+
});
4144

4245
if (count($value) < 3) {
43-
return implode(' ' . $glue . ' ', $modifiedValues);
46+
return implode(' ' . $conjunction . ' ', $modifiedValues);
4447
}
4548

4649
$last = array_pop($modifiedValues);
4750

48-
return implode(', ', $modifiedValues) . ', ' . $glue . ' ' . $last;
51+
return implode(', ', $modifiedValues) . ', ' . $conjunction . ' ' . $last;
4952
}
5053
}

src/Modifiers/TransModifier.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Modifiers;
6+
7+
use Respect\StringFormatter\BypassTranslator;
8+
use Respect\StringFormatter\Modifier;
9+
use Symfony\Contracts\Translation\TranslatorInterface;
10+
11+
use function is_string;
12+
13+
final readonly class TransModifier implements Modifier
14+
{
15+
public function __construct(
16+
private Modifier $nextModifier,
17+
private TranslatorInterface $translator = new BypassTranslator(),
18+
) {
19+
}
20+
21+
public function modify(mixed $value, string|null $pipe): string
22+
{
23+
if ($pipe !== 'trans') {
24+
return $this->nextModifier->modify($value, $pipe);
25+
}
26+
27+
if (!is_string($value)) {
28+
return $this->nextModifier->modify($value, null);
29+
}
30+
31+
return $this->translator->trans($value);
32+
}
33+
}

src/PlaceholderFormatter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Respect\StringFormatter\Modifiers\ListModifier;
88
use Respect\StringFormatter\Modifiers\QuoteModifier;
99
use Respect\StringFormatter\Modifiers\StringifyModifier;
10+
use Respect\StringFormatter\Modifiers\TransModifier;
1011

1112
use function array_key_exists;
1213
use function is_string;
@@ -17,7 +18,7 @@
1718
/** @param array<string, mixed> $parameters */
1819
public function __construct(
1920
private array $parameters,
20-
private Modifier $modifier = new QuoteModifier(new ListModifier(new StringifyModifier())),
21+
private Modifier $modifier = new TransModifier(new QuoteModifier(new ListModifier(new StringifyModifier()))),
2122
) {
2223
}
2324

tests/Helper/TestingTranslator.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Helper;
6+
7+
use Symfony\Contracts\Translation\LocaleAwareInterface;
8+
use Symfony\Contracts\Translation\TranslatorInterface;
9+
10+
/**
11+
* Dumb test implementation of TranslatorInterface
12+
*
13+
* This implementation simply replaces input strings with mapped replacements
14+
* and doesn't implement any actual translation logic.
15+
* Used only to test that the TransModifier is using the translator correctly.
16+
*/
17+
final class TestingTranslator implements TranslatorInterface, LocaleAwareInterface
18+
{
19+
/** @param array<string, string> $translations */
20+
public function __construct(
21+
private array $translations = [],
22+
) {
23+
}
24+
25+
/** @param array<string, mixed> $parameters */
26+
public function trans(
27+
string $id,
28+
array $parameters = [],
29+
string|null $domain = null,
30+
string|null $locale = null,
31+
): string {
32+
return $this->translations[$id] ?? $id;
33+
}
34+
35+
public function getLocale(): string
36+
{
37+
return 'en';
38+
}
39+
40+
public function setLocale(string $locale): void
41+
{
42+
// Dummy implementation - not needed for testing
43+
}
44+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Unit;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use PHPUnit\Framework\TestCase;
10+
use Respect\StringFormatter\BypassTranslator;
11+
12+
#[CoversClass(BypassTranslator::class)]
13+
final class BypassTranslatorTest extends TestCase
14+
{
15+
private BypassTranslator $translator;
16+
17+
protected function setUp(): void
18+
{
19+
parent::setUp();
20+
21+
$this->translator = new BypassTranslator();
22+
}
23+
24+
#[Test]
25+
public function itShouldReturnOriginalIdForTranslation(): void
26+
{
27+
$id = 'some.translation.key';
28+
$parameters = ['param1' => 'value1'];
29+
$domain = 'messages';
30+
$locale = 'en';
31+
32+
$result = $this->translator->trans($id, $parameters, $domain, $locale);
33+
34+
self::assertSame($id, $result);
35+
}
36+
37+
#[Test]
38+
public function itShouldReturnOriginalIdForTranslationWithMinimalParameters(): void
39+
{
40+
$id = 'simple.key';
41+
42+
$result = $this->translator->trans($id);
43+
44+
self::assertSame($id, $result);
45+
}
46+
47+
#[Test]
48+
public function itShouldReturnOriginalIdForTranslationWithEmptyParameters(): void
49+
{
50+
$id = 'key.with.no.params';
51+
52+
$result = $this->translator->trans($id, []);
53+
54+
self::assertSame($id, $result);
55+
}
56+
57+
#[Test]
58+
public function itShouldReturnOriginalIdForTranslationWithNullDomainAndLocale(): void
59+
{
60+
$id = 'key.with.nulls';
61+
62+
$result = $this->translator->trans($id, ['param' => 'value'], null, null);
63+
64+
self::assertSame($id, $result);
65+
}
66+
67+
#[Test]
68+
public function itShouldAlwaysReturnEnglishAsDefaultLocale(): void
69+
{
70+
$locale = $this->translator->getLocale();
71+
72+
self::assertSame('en', $locale);
73+
}
74+
75+
#[Test]
76+
public function itShouldHandleEmptyStringTranslation(): void
77+
{
78+
$result = $this->translator->trans('');
79+
80+
self::assertSame('', $result);
81+
}
82+
83+
#[Test]
84+
public function itShouldHandleComplexTranslationKeyId(): void
85+
{
86+
$complexId = 'nested.deep.very.complex.translation.key.with.dots';
87+
88+
$result = $this->translator->trans($complexId, ['param' => 'value'], 'domain', 'fr_FR');
89+
90+
self::assertSame($complexId, $result);
91+
}
92+
93+
#[Test]
94+
public function itShouldHandleSpecialCharactersInTranslationKey(): void
95+
{
96+
$specialId = 'key.with-special_chars@123#$%';
97+
98+
$result = $this->translator->trans($specialId);
99+
100+
self::assertSame($specialId, $result);
101+
}
102+
}

0 commit comments

Comments
 (0)