From 3b919450b0190cb06500fbf5cd1737fc795cfdec Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 11:23:03 +0200 Subject: [PATCH 1/9] Add `json` helper --- .../web/frontend/helpers/Essentials.class.php | 3 ++ .../unittest/EssentialsTest.class.php | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index 9b58f36..819a5ee 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -8,6 +8,9 @@ public function helpers() { yield 'encode' => function($in, $context, $options) { return rawurlencode($options[0] ?? ''); }; + yield 'json' => function($in, $context, $options) { + return json_encode($options[0] ?? null, isset($options['format']) ? JSON_PRETTY_PRINT : 0); + }; yield 'equals' => function($in, $context, $options) { return (int)(($options[0] ?? null) === ($options[1] ?? null)); }; diff --git a/src/test/php/web/frontend/unittest/EssentialsTest.class.php b/src/test/php/web/frontend/unittest/EssentialsTest.class.php index 04eab04..1b10bc1 100755 --- a/src/test/php/web/frontend/unittest/EssentialsTest.class.php +++ b/src/test/php/web/frontend/unittest/EssentialsTest.class.php @@ -12,6 +12,42 @@ public function url_encode() { ); } + #[Test] + public function json_string() { + Assert::equals( + 'let str = "He said \\"hello \\u4e16\\u754c!\\"\\n";', + $this->transform('let str = {{&json input}};', ['input' => 'He said "hello 世界!"'."\n"]) + ); + } + + #[Test] + public function json_object() { + Assert::equals( + 'let obj = {"hello":["World",true]};', + $this->transform('let obj = {{&json input}};', ['input' => ['hello' => ['World', true]]]) + ); + } + + #[Test] + public function formatted_json() { + Assert::equals( + <<<'JSON' + { + "hello": [ + "World", + true + ] + } + JSON, + $this->transform('{{&json input format=true}}', ['input' => ['hello' => ['World', true]]]) + ); + } + + #[Test, Values([['', '"<\\/script>"'], ['// END', '"\\/\\/ END"']])] + public function forward_slashes_escaped($input, $expected) { + Assert::equals($expected, $this->transform('{{&json input}}', ['input' => $input])); + } + #[Test, Values(['{{equals "A" "A"}}', '{{equals "A" a}}', '{{equals a a}}'])] public function are_equal($template) { Assert::equals('1', $this->transform($template, ['a' => 'A'])); From b48b64836c339586275f417d1cc09ae18de66a9b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 11:30:47 +0200 Subject: [PATCH 2/9] Fix format=false --- src/main/php/web/frontend/helpers/Essentials.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index 819a5ee..7fd855b 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -9,7 +9,7 @@ public function helpers() { return rawurlencode($options[0] ?? ''); }; yield 'json' => function($in, $context, $options) { - return json_encode($options[0] ?? null, isset($options['format']) ? JSON_PRETTY_PRINT : 0); + return json_encode($options[0] ?? null, ($options['format'] ?? false) ? JSON_PRETTY_PRINT : 0); }; yield 'equals' => function($in, $context, $options) { return (int)(($options[0] ?? null) === ($options[1] ?? null)); From 56903346762ea9c0ed044910304288553b402c0f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 11:47:16 +0200 Subject: [PATCH 3/9] Document `json` helper [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d987aa3..4081c9a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Helpers On top of the [built-in functionality in Handlebars](https://github.com/xp-forge/handlebars), this library includes the following essential helpers: * `encode`: Performs URL-encoding +* `json`: Performs JSON encoding, pretty-printing when given `format=true`. * `equals`: Tests arguments for equality * `contains`: Tests whether a string or array contains a certain value * `size`: Returns string length or array size From ebfc4bd305b4578468caf257212954b7b454a2c2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:05:47 +0200 Subject: [PATCH 4/9] Support iterables in `json` helper --- .../web/frontend/helpers/Essentials.class.php | 7 ++++++- .../frontend/unittest/EssentialsTest.class.php | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index 7fd855b..3714252 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -1,5 +1,7 @@ function($in, $context, $options) { - return json_encode($options[0] ?? null, ($options['format'] ?? false) ? JSON_PRETTY_PRINT : 0); + static $format; + $s= new StringOutput(($options['format'] ?? false) ? ($format??= Format::wrapped(' ')) : Format::$DEFAULT); + $s->write($options[0] ?? null); + return $s->bytes(); }; yield 'equals' => function($in, $context, $options) { return (int)(($options[0] ?? null) === ($options[1] ?? null)); diff --git a/src/test/php/web/frontend/unittest/EssentialsTest.class.php b/src/test/php/web/frontend/unittest/EssentialsTest.class.php index 1b10bc1..b4c4aaf 100755 --- a/src/test/php/web/frontend/unittest/EssentialsTest.class.php +++ b/src/test/php/web/frontend/unittest/EssentialsTest.class.php @@ -28,17 +28,19 @@ public function json_object() { ); } + #[Test] + public function json_iterable() { + $numbers= function() { yield 1; yield 2; yield 3; }; + Assert::equals( + 'let it = [1,2,3];', + $this->transform('let it = {{&json input}};', ['input' => $numbers()]) + ); + } + #[Test] public function formatted_json() { Assert::equals( - <<<'JSON' - { - "hello": [ - "World", - true - ] - } - JSON, + "{\n \"hello\": [\"World\", true]\n}", $this->transform('{{&json input format=true}}', ['input' => ['hello' => ['World', true]]]) ); } From 63b6ed98a1c498b045fb9c3f5a25df2b47d2d9d5 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:06:04 +0200 Subject: [PATCH 5/9] Add explicit dependency on JSON library --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 2bfc13c..a78b21c 100755 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "xp-forge/frontend": "^7.0 | ^6.0", "xp-forge/handlebars": "^10.0 | ^9.3", "xp-forge/yaml": "^9.0 | ^8.0 | ^7.0 | ^6.0", + "xp-forge/json": "^6.0 | ^5.0", "php": ">=7.4.0" }, "require-dev" : { From c84a566c537d16737f2f6de9812f3ca62a179c1d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:15:23 +0200 Subject: [PATCH 6/9] Support marshallable objects --- .../web/frontend/helpers/Essentials.class.php | 6 +++-- .../unittest/EssentialsTest.class.php | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index 3714252..efcbfdf 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -1,6 +1,7 @@ function($in, $context, $options) { - static $format; + static $marshalling, $format; + $s= new StringOutput(($options['format'] ?? false) ? ($format??= Format::wrapped(' ')) : Format::$DEFAULT); - $s->write($options[0] ?? null); + $s->write(($marshalling??= new Marshalling())->marshal($options[0] ?? null)); return $s->bytes(); }; yield 'equals' => function($in, $context, $options) { diff --git a/src/test/php/web/frontend/unittest/EssentialsTest.class.php b/src/test/php/web/frontend/unittest/EssentialsTest.class.php index b4c4aaf..7d1b9e5 100755 --- a/src/test/php/web/frontend/unittest/EssentialsTest.class.php +++ b/src/test/php/web/frontend/unittest/EssentialsTest.class.php @@ -1,9 +1,22 @@ ['World', true]]]; + yield [new class() { public $hello= ['World', true]; }]; + } + + /** @return iterable */ + private function iterables() { + yield [(function() { yield 1; yield 2; yield 3; })()]; + yield [new ArrayIterator([1, 2, 3])]; + } + #[Test] public function url_encode() { Assert::equals( @@ -20,20 +33,19 @@ public function json_string() { ); } - #[Test] - public function json_object() { + #[Test, Values(from: 'objects')] + public function json_object($object) { Assert::equals( 'let obj = {"hello":["World",true]};', - $this->transform('let obj = {{&json input}};', ['input' => ['hello' => ['World', true]]]) + $this->transform('let obj = {{&json input}};', ['input' => $object]) ); } - #[Test] - public function json_iterable() { - $numbers= function() { yield 1; yield 2; yield 3; }; + #[Test, Values(from: 'iterables')] + public function json_iterable($iterable) { Assert::equals( 'let it = [1,2,3];', - $this->transform('let it = {{&json input}};', ['input' => $numbers()]) + $this->transform('let it = {{&json input}};', ['input' => $iterable]) ); } From 98b0c4e52d325b3a9f48ca2a2bcfa19252aeb283 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:20:21 +0200 Subject: [PATCH 7/9] QA: Import from global namespace consistently --- src/main/php/web/frontend/helpers/Essentials.class.php | 3 ++- src/test/php/web/frontend/unittest/EssentialsTest.class.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index efcbfdf..00fefc8 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -1,5 +1,6 @@ function($in, $context, $options) { if (!isset($options[0])) { return 0; - } else if ($options[0] instanceof \Countable || is_array($options[0])) { + } else if ($options[0] instanceof Countable || is_array($options[0])) { return sizeof($options[0]); } else { return strlen($options[0]); diff --git a/src/test/php/web/frontend/unittest/EssentialsTest.class.php b/src/test/php/web/frontend/unittest/EssentialsTest.class.php index 7d1b9e5..f292555 100755 --- a/src/test/php/web/frontend/unittest/EssentialsTest.class.php +++ b/src/test/php/web/frontend/unittest/EssentialsTest.class.php @@ -1,6 +1,6 @@ 'Test', 'numbers' => [1, 2, 3], 'sizes' => ['S' => 12.99, 'M' => 13.99], - 'count' => new class() implements \Countable { public function count(): int { return 1; } }, + 'count' => new class() implements Countable { public function count(): int { return 1; } }, 'empty' => [], ])); } From 3669289628c58997660cefc19efdc1a4d8b30ca2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:27:17 +0200 Subject: [PATCH 8/9] Directly use text.json.WrappedFormat --- src/main/php/web/frontend/helpers/Essentials.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/php/web/frontend/helpers/Essentials.class.php b/src/main/php/web/frontend/helpers/Essentials.class.php index 00fefc8..6c4b7e6 100755 --- a/src/main/php/web/frontend/helpers/Essentials.class.php +++ b/src/main/php/web/frontend/helpers/Essentials.class.php @@ -1,7 +1,7 @@ function($in, $context, $options) { static $marshalling, $format; - $s= new StringOutput(($options['format'] ?? false) ? ($format??= Format::wrapped(' ')) : Format::$DEFAULT); + $s= new StringOutput(($options['format'] ?? false) ? ($format??= new WrappedFormat(' ')) : null); $s->write(($marshalling??= new Marshalling())->marshal($options[0] ?? null)); return $s->bytes(); }; From 9443d55c290a72965dd816549cfae00a3a5b8821 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 27 Sep 2025 12:30:33 +0200 Subject: [PATCH 9/9] Verify forward slashes are escaped in nested contexts --- src/test/php/web/frontend/unittest/EssentialsTest.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/php/web/frontend/unittest/EssentialsTest.class.php b/src/test/php/web/frontend/unittest/EssentialsTest.class.php index f292555..761b1a5 100755 --- a/src/test/php/web/frontend/unittest/EssentialsTest.class.php +++ b/src/test/php/web/frontend/unittest/EssentialsTest.class.php @@ -57,7 +57,7 @@ public function formatted_json() { ); } - #[Test, Values([['', '"<\\/script>"'], ['// END', '"\\/\\/ END"']])] + #[Test, Values([['', '"<\\/script>"'], ['// END', '"\\/\\/ END"'], [['tag' => ''], '{"tag":"<\\/a>"}']])] public function forward_slashes_escaped($input, $expected) { Assert::equals($expected, $this->transform('{{&json input}}', ['input' => $input])); }