Skip to content

Commit 63c1ca5

Browse files
committed
Add modifier support to PlaceholderFormatter
Although basic interpolation is useful, we often want to handle parameters differently, not using the standard modification we apply to them. This commit introduces modifiers, a concept already present in Respect\Validation. We use a familiar `{{value|modifier}}` syntax found in many popular template engines. To handle different types of modifiers, I implemented a Chain of Responsibility, where each modifier delegates to the next one when it cannot handle them. This allows us to pipe values through specific logic, such as the new `|raw` modifier, which provides clean scalar output by bypassing the default stringifier formatting. The `StringifyModifier` remains the default fallback, ensuring that we don't lose the robust type-handling we previously built, but I’ve also made some changes to it. I realise that for Respect. Validation, we have a strong need to add "quotes" around strings because they represent values that were just validated, but in the context of placeholder replacement, this behaviour is undesirable. I’ve created the `QuoteUnmodifiedModifier`, so we can keep the behaviour we have in Validation when we use this library, but it won’t be added to the standard in the default chain of responsibility I mentioned. Assisted-by: OpenCode (GLM-4.6) Assisted-by: Gemini 3 (Thinking)
1 parent fb2db2d commit 63c1ca5

18 files changed

Lines changed: 1325 additions & 117 deletions

docs/PlaceholderFormatter.md

Lines changed: 101 additions & 104 deletions
Large diffs are not rendered by default.
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
# Creating Custom Modifiers
2+
3+
You can create custom modifiers by implementing the `Modifier` interface. This allows you to add custom transformations to placeholder values.
4+
5+
## The Modifier Interface
6+
7+
```php
8+
use Respect\StringFormatter\Modifier;
9+
10+
interface Modifier
11+
{
12+
public function modify(mixed $value, string|null $pipe): string;
13+
}
14+
```
15+
16+
- `$value`: The placeholder value to transform
17+
- `$pipe`: The modifier name from the template (e.g., "upper" in `{{name|upper}}`)
18+
- Return: The transformed string value
19+
20+
## Basic Custom Modifier
21+
22+
Here's a simple uppercase modifier:
23+
24+
```php
25+
use Respect\StringFormatter\Modifier;
26+
use Respect\StringFormatter\Modifier\StringifyModifier;
27+
28+
final class UppercaseModifier implements Modifier
29+
{
30+
public function __construct(
31+
private Modifier $nextModifier,
32+
) {
33+
}
34+
35+
public function modify(mixed $value, string|null $pipe): string
36+
{
37+
if ($pipe === 'upper') {
38+
return strtoupper($this->nextModifier->modify($value, null));
39+
}
40+
41+
return $this->nextModifier->modify($value, $pipe);
42+
}
43+
}
44+
```
45+
46+
## Usage Example
47+
48+
```php
49+
use Respect\StringFormatter\PlaceholderFormatter;
50+
use Respect\StringFormatter\Modifier\RawModifier;
51+
use Respect\StringFormatter\Modifier\StringifyModifier;
52+
53+
// Create the modifier chain
54+
$modifierChain = new UppercaseModifier(
55+
new RawModifier(
56+
new StringifyModifier()
57+
)
58+
);
59+
60+
$formatter = new PlaceholderFormatter(
61+
['message' => 'hello world', 'name' => 'John'],
62+
$modifierChain
63+
);
64+
65+
echo $formatter->format('{{message|upper}} says: {{name|upper}}');
66+
// Outputs: "HELLO WORLD says: JOHN"
67+
```
68+
69+
## Modifiers with Parameters
70+
71+
Some modifiers may need to handle parameters:
72+
73+
```php
74+
use Respect\StringFormatter\Modifier;
75+
76+
final class TruncateModifier implements Modifier
77+
{
78+
public function __construct(
79+
private Modifier $nextModifier,
80+
) {
81+
}
82+
83+
public function modify(mixed $value, string|null $pipe): string
84+
{
85+
if ($pipe && str_starts_with($pipe, 'truncate:')) {
86+
$length = (int) substr($pipe, 10); // Remove 'truncate:' prefix
87+
$strValue = $this->nextModifier->modify($value, null);
88+
return strlen($strValue) > $length
89+
? substr($strValue, 0, $length) . '...'
90+
: $strValue;
91+
}
92+
93+
return $this->nextModifier->modify($value, $pipe);
94+
}
95+
}
96+
```
97+
98+
```php
99+
// Usage with parameter
100+
$formatter = new PlaceholderFormatter(
101+
['longText' => 'This is a very long piece of text'],
102+
new TruncateModifier(new StringifyModifier())
103+
);
104+
105+
echo $formatter->format('{{longText|truncate:10}}');
106+
// Outputs: "This is a..."
107+
```
108+
109+
## Best Practices
110+
111+
### 1. Always Chain to Next Modifier
112+
113+
Your modifier should always call `$this->nextModifier->modify()` for fallback behavior:
114+
115+
```php
116+
public function modify(mixed $value, string|null $pipe): string
117+
{
118+
// Handle your specific modifier
119+
if ($pipe === 'my_modifier') {
120+
return $this->processValue($value);
121+
}
122+
123+
// Pass through to next modifier
124+
return $this->nextModifier->modify($value, $pipe);
125+
}
126+
```
127+
128+
### 2. Handle Null Pipes
129+
130+
The `$pipe` parameter can be `null` when no modifier is specified:
131+
132+
```php
133+
public function modify(mixed $value, string|null $pipe): string
134+
{
135+
if ($pipe === null) {
136+
// No modifier specified - pass through
137+
return $this->nextModifier->modify($value, $pipe);
138+
}
139+
140+
// Handle specific modifiers
141+
// ...
142+
}
143+
```
144+
145+
### 3. Type Safety
146+
147+
Ensure your modifier handles various input types gracefully:
148+
149+
```php
150+
public function modify(mixed $value, string|null $pipe): string
151+
{
152+
if ($pipe === 'length') {
153+
if (is_string($value) || is_array($value)) {
154+
return (string) count($value);
155+
}
156+
157+
// Handle non-iterable types
158+
return $this->nextModifier->modify($value, $pipe);
159+
}
160+
161+
return $this->nextModifier->modify($value, $pipe);
162+
}
163+
```
164+
165+
### 4. Performance Considerations
166+
167+
For high-performance modifiers, consider:
168+
169+
- Caching expensive computations
170+
- Avoiding unnecessary stringifier calls
171+
- Using type checking for early returns
172+
173+
```php
174+
final class JsonEncodeModifier implements Modifier
175+
{
176+
private array $cache = [];
177+
178+
public function __construct(
179+
private Modifier $nextModifier,
180+
) {
181+
}
182+
183+
public function modify(mixed $value, string|null $pipe): string
184+
{
185+
if ($pipe === 'json') {
186+
$cacheKey = serialize($value);
187+
return $this->cache[$cacheKey] ??= json_encode($value, JSON_THROW_ON_ERROR);
188+
}
189+
190+
return $this->nextModifier->modify($value, $pipe);
191+
}
192+
}
193+
```
194+
195+
## Advanced Examples
196+
197+
### Currency Modifier
198+
199+
```php
200+
final class CurrencyModifier implements Modifier
201+
{
202+
public function __construct(
203+
private Modifier $nextModifier,
204+
) {
205+
}
206+
207+
public function modify(mixed $value, string|null $pipe): string
208+
{
209+
if ($pipe === 'currency') {
210+
if (!is_numeric($value)) {
211+
return $this->nextModifier->modify($value, $pipe);
212+
}
213+
214+
return '$' . number_format((float) $value, 2);
215+
}
216+
217+
return $this->nextModifier->modify($value, $pipe);
218+
}
219+
}
220+
```
221+
222+
### Date Format Modifier
223+
224+
```php
225+
final class DateFormatModifier implements Modifier
226+
{
227+
public function __construct(
228+
private Modifier $nextModifier,
229+
) {
230+
}
231+
232+
public function modify(mixed $value, string|null $pipe): string
233+
{
234+
if ($pipe && str_starts_with($pipe, 'date:')) {
235+
$format = substr($pipe, 5); // Remove 'date:' prefix
236+
237+
if ($value instanceof DateTimeInterface) {
238+
return $value->format($format);
239+
}
240+
241+
if (is_string($value)) {
242+
try {
243+
$date = new DateTime($value);
244+
return $date->format($format);
245+
} catch (Exception) {
246+
// Invalid date format
247+
}
248+
}
249+
250+
return $this->nextModifier->modify($value, $pipe);
251+
}
252+
253+
return $this->nextModifier->modify($value, $pipe);
254+
}
255+
}
256+
```
257+
258+
## Testing Custom Modifiers
259+
260+
Unit test your modifiers thoroughly:
261+
262+
```php
263+
use PHPUnit\Framework\TestCase;
264+
265+
class UppercaseModifierTest extends TestCase
266+
{
267+
public function testUppercase(): void
268+
{
269+
$nextModified = $this->createMock(Modifier::class);
270+
$nextModified->method('modify')->willReturn('test');
271+
272+
$modifier = new UppercaseModifier($nextModified);
273+
274+
$result = $modifier->modify('hello', 'upper');
275+
276+
$this->assertEquals('TEST', $result);
277+
}
278+
279+
public function testPassThrough(): void
280+
{
281+
$nextModified = $this->createMock(Modifier::class);
282+
$nextModified->expects($this->once())
283+
->method('modify')
284+
->with('value', 'other_modifier')
285+
->willReturn('output');
286+
287+
$modifier = new UppercaseModifier($nextModified);
288+
289+
$result = $modifier->modify('value', 'other_modifier');
290+
291+
$this->assertEquals('output', $result);
292+
}
293+
}
294+
```
295+
296+
## Integration with PlaceholderFormatter
297+
298+
When using custom modifiers, you need to construct the full chain:
299+
300+
```php
301+
// Example: CustomModifier -> RawModifier -> StringifyModifier
302+
$modifier = new MyCustomModifier(
303+
new RawModifier(
304+
new StringifyModifier()
305+
)
306+
);
307+
308+
$formatter = new PlaceholderFormatter($data, $modifier);
309+
```
310+
311+
Or use the existing default chain and prepend:
312+
313+
```php
314+
$defaultChain = new RawModifier(new StringifyModifier());
315+
$modifier = new MyCustomModifier($defaultChain);
316+
$formatter = new PlaceholderFormatter($data, $modifier);
317+
```

0 commit comments

Comments
 (0)