Skip to content

Commit beceb3d

Browse files
committed
added |json filter with context-aware attribute encoding
In HTML attribute context (via ExpressionAttributeNode), `|json` routes to HtmlHelpers::formatJsonAttribute() which uses the same JSON + smart-quoting mechanism as formatDataAttribute(). Elsewhere it uses Helpers::encodeJson(), refactored out of escapeJs() without the HtmlStringable unwrap branch.
1 parent 14dea9f commit beceb3d

7 files changed

Lines changed: 262 additions & 11 deletions

File tree

src/Latte/Compiler/Nodes/Html/ExpressionAttributeNode.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ public function __construct(
3535
public function print(PrintContext $context): string
3636
{
3737
if ($context->getEscaper()->getContentType() === ContentType::Html) {
38-
$type = $this->modifier->removeFilter('toggle') ? 'bool' : LR\HtmlHelpers::classifyAttributeType($this->name);
38+
$type = match (true) {
39+
$this->modifier->removeFilter('toggle') !== null => 'bool',
40+
$this->modifier->removeFilter('json', 'last') !== null => 'json',
41+
default => LR\HtmlHelpers::classifyAttributeType($this->name),
42+
};
3943
$method = 'LR\HtmlHelpers::format' . ucfirst($type) . 'Attribute';
4044
} else {
4145
$method = 'LR\XmlHelpers::formatAttribute';

src/Latte/Compiler/Nodes/Php/ModifierNode.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,22 @@ public function hasFilter(string $name): bool
4545

4646

4747
/**
48-
* Removes and returns the first filter with the given name, or null if not found.
48+
* Removes and returns a matching filter, or null if no matching filter is found at the requested position.
49+
* Position: '' (default) = anywhere, 'first' = only at index 0, 'last' = only at the last index.
4950
*/
50-
public function removeFilter(string $name): ?FilterNode
51+
public function removeFilter(string $name, string $position = ''): ?FilterNode
5152
{
52-
foreach ($this->filters as $i => $filter) {
53-
if ($filter->name->name === $name) {
53+
$indexes = match ($position) {
54+
'' => array_keys($this->filters),
55+
'first' => $this->filters ? [array_key_first($this->filters)] : [],
56+
'last' => $this->filters ? [array_key_last($this->filters)] : [],
57+
default => throw new \InvalidArgumentException("Invalid position '$position', expected '', 'first' or 'last'."),
58+
};
59+
foreach ($indexes as $i) {
60+
if ($this->filters[$i]->name->name === $name) {
5461
return array_splice($this->filters, $i, 1)[0];
5562
}
5663
}
57-
5864
return null;
5965
}
6066

src/Latte/Essential/CoreExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ public function getFilters(): array
150150
'implode' => $this->filters->implode(...),
151151
'indent' => $this->filters->indent(...),
152152
'join' => $this->filters->implode(...),
153+
'json' => Latte\Runtime\Helpers::encodeJson(...),
153154
'last' => $this->filters->last(...),
154155
'length' => $this->filters->length(...),
155156
'limit' => fn(string|iterable $value, int $length) => Filters::slice($value, 0, $length, preserveKeys: true),

src/Latte/Runtime/Helpers.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,16 @@ public static function escapeJs(mixed $s): string
5050
$s = $s->__toString();
5151
}
5252

53-
$json = json_encode($s, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
53+
return self::encodeJson($s);
54+
}
55+
56+
57+
/**
58+
* Encodes value as JSON, safe for embedding into HTML/XML (escapes ]]>, <!, </).
59+
*/
60+
public static function encodeJson(mixed $value): string
61+
{
62+
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
5463
return str_replace([']]>', '<!', '</'], [']]\u003E', '\u003C!', '<\/'], $json);
5564
}
5665

src/Latte/Runtime/HtmlHelpers.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,26 @@ public static function formatDataAttribute(string $namePart, mixed $value, bool
243243
if ($migrationWarnings && (is_bool($value) || $value === null)) {
244244
self::triggerMigrationWarning(trim($namePart), $value);
245245
}
246-
$escape = fn($value) => str_contains($value, '"')
247-
? "'" . str_replace(['&', "'"], ['&amp;', '&apos;'], $value) . "'"
248-
: '"' . str_replace(['&', '"'], ['&amp;', '&quot;'], $value) . '"';
249246
return match (true) {
250247
is_bool($value) => $namePart . '="' . ($value ? 'true' : 'false') . '"',
251-
is_array($value) || $value instanceof \stdClass => $namePart . '=' . $escape(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)),
248+
is_array($value) || $value instanceof \stdClass => self::formatJsonAttribute($namePart, $value),
252249
default => self::formatAttribute($namePart, $value),
253250
};
254251
}
255252

256253

254+
/**
255+
* Formats HTML attribute with JSON-encoded value.
256+
*/
257+
public static function formatJsonAttribute(string $namePart, mixed $value): string
258+
{
259+
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
260+
return $namePart . '=' . (str_contains($json, '"')
261+
? "'" . str_replace(['&', "'"], ['&amp;', '&apos;'], $json) . "'"
262+
: '"' . str_replace(['&', '"'], ['&amp;', '&quot;'], $json) . '"');
263+
}
264+
265+
257266
/**
258267
* Formats aria-* HTML attribute.
259268
*/

tests/filters/CoreExtension.filters.phpt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Assert::true(str_starts_with(($filters['datastream'])('abc'), 'data:'));
2727
Assert::same(['a', 'b'], ($filters['explode'])('a,b', ','));
2828
Assert::same('a,b', ($filters['implode'])(['a', 'b'], ','));
2929
Assert::same('a,b', ($filters['join'])(['a', 'b'], ','));
30+
Assert::same('{"a":1}', ($filters['json'])(['a' => 1]));
3031
Assert::same('abc', ($filters['trim'])(new FilterInfo('html'), ' abc '));
3132
Assert::same('abc', ($filters['strip'])(new FilterInfo('html'), ' abc '));
3233
Assert::same('abc', ($filters['stripHtml'])(new FilterInfo('html'), '<b>abc</b>'));

tests/filters/json.phpt

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: |json filter
5+
*/
6+
7+
use Latte\Runtime\Helpers;
8+
use Tester\Assert;
9+
10+
require __DIR__ . '/../bootstrap.php';
11+
12+
13+
class MyHtmlStringable implements Latte\Runtime\HtmlStringable
14+
{
15+
public function __toString(): string
16+
{
17+
return '<b>html</b>';
18+
}
19+
}
20+
21+
22+
class TestJsonSerializable implements \JsonSerializable
23+
{
24+
public function jsonSerialize(): array
25+
{
26+
return ['key' => 'value'];
27+
}
28+
}
29+
30+
31+
test('scalars', function () {
32+
Assert::same('null', Helpers::encodeJson(null));
33+
Assert::same('true', Helpers::encodeJson(true));
34+
Assert::same('false', Helpers::encodeJson(false));
35+
Assert::same('0', Helpers::encodeJson(0));
36+
Assert::same('42', Helpers::encodeJson(42));
37+
Assert::same('1.5', Helpers::encodeJson(1.5));
38+
Assert::same('""', Helpers::encodeJson(''));
39+
Assert::same('"abc"', Helpers::encodeJson('abc'));
40+
});
41+
42+
43+
test('arrays and objects', function () {
44+
Assert::same('[]', Helpers::encodeJson([]));
45+
Assert::same('[1,2,3]', Helpers::encodeJson([1, 2, 3]));
46+
Assert::same('{"a":1,"b":2}', Helpers::encodeJson(['a' => 1, 'b' => 2]));
47+
Assert::same('{"a":1}', Helpers::encodeJson((object) ['a' => 1]));
48+
Assert::same('{"key":"value"}', Helpers::encodeJson(new TestJsonSerializable));
49+
});
50+
51+
52+
test('UTF-8 preserved', function () {
53+
Assert::same('"čau"', Helpers::encodeJson('čau'));
54+
Assert::same('"日本"', Helpers::encodeJson('日本'));
55+
});
56+
57+
58+
test('slashes unescaped', function () {
59+
Assert::same('"a/b"', Helpers::encodeJson('a/b'));
60+
Assert::same('"http://example.com"', Helpers::encodeJson('http://example.com'));
61+
});
62+
63+
64+
test('dangerous sequences escaped', function () {
65+
Assert::same('"]]\u003E"', Helpers::encodeJson(']]>'));
66+
Assert::same('"\u003C!--"', Helpers::encodeJson('<!--'));
67+
Assert::same('"<\/script>"', Helpers::encodeJson('</script>'));
68+
});
69+
70+
71+
test('HtmlStringable is NOT unwrapped (unlike escapeJs)', function () {
72+
// object with only private/empty public props serializes to {}
73+
Assert::same('{}', Helpers::encodeJson(new MyHtmlStringable));
74+
// escapeJs for comparison unwraps to string
75+
Assert::same('"<b>html<\/b>"', Helpers::escapeJs(new MyHtmlStringable));
76+
});
77+
78+
79+
test('via Latte: HTML text context', function () {
80+
// { escaped to &#123;, " kept literal (ENT_NOQUOTES)
81+
Assert::same(
82+
'&#123;"a":1}',
83+
createLatte()->renderToString('{$arr|json}', ['arr' => ['a' => 1]]),
84+
);
85+
});
86+
87+
88+
test('via Latte: JS context', function () {
89+
$latte = createLatte();
90+
91+
// auto-escapeJs wraps |json output as a JS string literal
92+
Assert::same(
93+
'<script>var x = "{\"a\":1}";</script>',
94+
$latte->renderToString('<script>var x = {$arr|json};</script>', ['arr' => ['a' => 1]]),
95+
);
96+
// use |noescape to get raw JSON in JS context
97+
Assert::same(
98+
'<script>var x = {"a":1};</script>',
99+
$latte->renderToString('<script>var x = {$arr|json|noescape};</script>', ['arr' => ['a' => 1]]),
100+
);
101+
});
102+
103+
104+
test('via Latte: HTML attribute context', function () {
105+
$latte = createLatte();
106+
107+
// array -> single quotes because JSON contains "
108+
Assert::same(
109+
'<meta content=\'{"a":1}\'>',
110+
$latte->renderToString('<meta content={$arr|json}>', ['arr' => ['a' => 1]]),
111+
);
112+
// string -> JSON-encoded as "abc", wrapped in single quotes
113+
Assert::same(
114+
'<meta content=\'"abc"\'>',
115+
$latte->renderToString('<meta content={$str|json}>', ['str' => 'abc']),
116+
);
117+
// number -> JSON literal, wrapped in double quotes (no " in JSON)
118+
Assert::same(
119+
'<meta content="42">',
120+
$latte->renderToString('<meta content={$n|json}>', ['n' => 42]),
121+
);
122+
// bool -> JSON literal true
123+
Assert::same(
124+
'<meta content="true">',
125+
$latte->renderToString('<meta content={$b|json}>', ['b' => true]),
126+
);
127+
// null -> JSON literal null
128+
Assert::same(
129+
'<meta content="null">',
130+
$latte->renderToString('<meta content={$v|json}>', ['v' => null]),
131+
);
132+
// float
133+
Assert::same(
134+
'<meta content="1.5">',
135+
$latte->renderToString('<meta content={$v|json}>', ['v' => 1.5]),
136+
);
137+
});
138+
139+
140+
test('via Latte: |json overrides special-attribute handling', function () {
141+
$latte = createLatte();
142+
143+
// class: without |json -> list attribute (space-joined)
144+
Assert::same(
145+
'<div class="a b"></div>',
146+
$latte->renderToString('<div class={$cls}></div>', ['cls' => ['a', 'b']]),
147+
);
148+
// class: with |json -> JSON array
149+
Assert::same(
150+
'<div class=\'["a","b"]\'></div>',
151+
$latte->renderToString('<div class={$cls|json}></div>', ['cls' => ['a', 'b']]),
152+
);
153+
// aria-*: with |json -> JSON-encoded
154+
Assert::same(
155+
'<div aria-x=\'{"a":1}\'></div>',
156+
$latte->renderToString('<div aria-x={$arr|json}></div>', ['arr' => ['a' => 1]]),
157+
);
158+
});
159+
160+
161+
test('via Latte: |json in event handler attribute', function () {
162+
// onclick is JS context, but |json detection fires first -> raw JSON literal
163+
Assert::same(
164+
'<div onclick=\'{"a":1}\'></div>',
165+
createLatte()->renderToString('<div onclick={$arr|json}></div>', ['arr' => ['a' => 1]]),
166+
);
167+
});
168+
169+
170+
test('via Latte: |json in XML template', function () {
171+
$latte = createLatte();
172+
$latte->setContentType(Latte\ContentType::Xml);
173+
174+
// XML: no compile-time detection, |json runs as regular filter and output is XML-escaped
175+
Assert::same(
176+
'<meta content="{&quot;a&quot;:1}"/>',
177+
$latte->renderToString('<meta content={$arr|json}/>', ['arr' => ['a' => 1]]),
178+
);
179+
});
180+
181+
182+
test('via Latte: data-* attribute', function () {
183+
$latte = createLatte();
184+
185+
// data-x without |json: auto JSON for arrays
186+
Assert::same(
187+
'<div data-x=\'{"a":1}\'></div>',
188+
$latte->renderToString('<div data-x={$arr}></div>', ['arr' => ['a' => 1]]),
189+
);
190+
// data-x with |json for array: same result
191+
Assert::same(
192+
'<div data-x=\'{"a":1}\'></div>',
193+
$latte->renderToString('<div data-x={$arr|json}></div>', ['arr' => ['a' => 1]]),
194+
);
195+
// data-x without |json: string passthrough
196+
Assert::same(
197+
'<div data-x="hello"></div>',
198+
$latte->renderToString('<div data-x={$str}></div>', ['str' => 'hello']),
199+
);
200+
// data-x with |json: string JSON-encoded as "hello"
201+
Assert::same(
202+
'<div data-x=\'"hello"\'></div>',
203+
$latte->renderToString('<div data-x={$str|json}></div>', ['str' => 'hello']),
204+
);
205+
});
206+
207+
208+
test('via Latte: chain with other filter', function () {
209+
$latte = createLatte();
210+
211+
// |json is last -> compile-time detection fires, |upper runs first, then formatJsonAttribute
212+
Assert::same(
213+
'<meta content=\'"HELLO"\'>',
214+
$latte->renderToString('<meta content={$s|upper|json}>', ['s' => 'hello']),
215+
);
216+
// |json is NOT last -> detection skipped, both filters run as regular and output is escaped by formatAttribute
217+
Assert::same(
218+
'<meta content="&quot;HELLO&quot;">',
219+
$latte->renderToString('<meta content={$s|json|upper}>', ['s' => 'hello']),
220+
);
221+
});

0 commit comments

Comments
 (0)