Skip to content

Commit 45fe8ae

Browse files
committed
Implement Handlebars::createFrame utility function
1 parent 8270d50 commit 45fe8ae

4 files changed

Lines changed: 49 additions & 41 deletions

File tree

src/Handlebars.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ public static function template(string $templateSpec): \Closure
4444
return eval($templateSpec);
4545
}
4646

47+
/**
48+
* Creates a child @data frame inheriting fields from the given frame.
49+
* Use this in block helpers before passing a data array to fn() or inverse(),
50+
* equivalent to Handlebars.createFrame() in Handlebars.js.
51+
* @param array<mixed> $data
52+
* @return array<mixed>
53+
*/
54+
public static function createFrame(array $data): array
55+
{
56+
$frame = $data;
57+
$frame['_parent'] = $data;
58+
return $frame;
59+
}
60+
4761
/**
4862
* HTML escapes the passed string, making it safe for rendering as text within HTML content.
4963
* The output of all expressions except for triple-braced expressions are passed through this method.

src/HelperOptions.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,7 @@ private function invokeBlock(\Closure $closure, mixed $context, mixed $data): st
102102

103103
if (isset($data['data'])) {
104104
$savedFrame = $cx->frame;
105-
// Fast path: only root in frame, no user @-data to inherit
106-
$newFrame = count($savedFrame) === 1 ? $data['data'] : array_replace($savedFrame, $data['data']);
107-
$newFrame['root'] = &$cx->data['root'];
108-
$newFrame['_parent'] = $savedFrame;
109-
$cx->frame = $newFrame;
105+
$cx->frame = $data['data'];
110106
}
111107

112108
if (isset($data['blockParams'])) {
@@ -159,18 +155,22 @@ public function iterate(array $items): string
159155
$ret = '';
160156
$i = 0;
161157
$outerFrame = $cx->frame;
162-
// Fast path: when only root is in the frame, skip array_replace.
163-
$simpleFrame = count($outerFrame) === 1;
164158
// Pre-allocate bpStack once; mutate [0][0] and [0][1] per iteration.
165159
// PHP COW ensures the inner array's refcount returns to 1 after $cb() returns,
166160
// so the next iteration's assignment is an in-place mutation, not a copy.
167161
$bpStack = [[null, null], ...$this->outerBlockParams];
168162

163+
// Pre-allocate the iteration frame once; only the 4 per-iteration keys mutate each loop.
164+
// array_replace inherits all outer frame variables (including @root) via PHP's reference-preserving
165+
// array copy — the same mechanism as Handlebars.createFrame() in HBS.js.
166+
$iterKeys = ['key' => null, 'index' => null, 'first' => null, 'last' => null, '_parent' => $outerFrame];
167+
$newFrame = array_replace($outerFrame, $iterKeys);
168+
169169
foreach ($items as $index => $value) {
170-
$iterData = ['key' => $index, 'index' => $i, 'first' => $i === 0, 'last' => $i === $last];
171-
$newFrame = $simpleFrame ? $iterData : array_replace($outerFrame, $iterData);
172-
$newFrame['root'] = &$cx->data['root'];
173-
$newFrame['_parent'] = $outerFrame;
170+
$newFrame['key'] = $index;
171+
$newFrame['index'] = $i;
172+
$newFrame['first'] = $i === 0;
173+
$newFrame['last'] = $i === $last;
174174
$cx->frame = $newFrame;
175175

176176
$bpStack[0][0] = $value;

tests/HandlebarsSpecTest.php

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,15 @@
11
<?php
22

3+
namespace DevTheorem\Handlebars\Test;
4+
35
use DevTheorem\Handlebars\Handlebars;
46
use DevTheorem\Handlebars\Options;
57
use PHPUnit\Framework\Attributes\DataProvider;
68
use PHPUnit\Framework\TestCase;
79

8-
/**
9-
* Used by vendor/jbboehr/handlebars-spec/spec/data.json
10-
*/
11-
class Utils
12-
{
13-
public static function createFrame(mixed $data): mixed
14-
{
15-
if (is_array($data)) {
16-
$r = [];
17-
foreach ($data as $k => $v) {
18-
$r[$k] = $v;
19-
}
20-
return $r;
21-
}
22-
return $data;
23-
}
24-
}
25-
2610
/**
2711
* @phpstan-type JsonSpec array{
28-
* file: string, no: int, message: string|null, data: null|int|bool|string|array<mixed>|stdClass,
12+
* file: string, no: int, message: string|null, data: null|int|bool|string|array<mixed>|\stdClass,
2913
* it: string, description: string, expected: string|null, helpers: array<mixed>,
3014
* partials: array<mixed>, compileOptions: array<mixed>, template: string,
3115
* exception: string|null, runtimeOptions: array<mixed>, number: string|null,
@@ -82,11 +66,7 @@ public function testSpecs(array $spec): void
8266
if (!isset($func['php'])) {
8367
$this->markTestIncomplete("No PHP helper code provided for [{$spec['file']}#{$spec['description']}]#{$spec['no']}");
8468
}
85-
$helper = self::patchSafeString($func['php']);
86-
$helper = str_replace('$options[\'name\']', '$options->name', $helper);
87-
$helper = str_replace('$options[\'data\']', '$options->data', $helper);
88-
$helper = str_replace('$options[\'hash\']', '$options->hash', $helper);
89-
$helper = str_replace('$arguments[count($arguments)-1][\'name\'];', '$arguments[count($arguments)-1]->name;', $helper);
69+
$helper = self::patchHelperCode($func['php']);
9070
$helpersList .= "\n '$name' => $helper,\n";
9171
eval('$helpers[\'' . $name . '\'] = ' . $helper . ';');
9272
}
@@ -182,7 +162,7 @@ public static function jsonSpecProvider(): array
182162

183163
$files = glob('vendor/jbboehr/handlebars-spec/spec/*.json');
184164
if ($files === false) {
185-
throw new Exception("Failed to read JSON spec files");
165+
throw new \Exception("Failed to read JSON spec files");
186166
}
187167

188168
foreach ($files as $file) {
@@ -192,7 +172,7 @@ public static function jsonSpecProvider(): array
192172
}
193173
$contents = file_get_contents($file);
194174
if ($contents === false) {
195-
throw new Exception("Failed to read JSON spec file {$file}");
175+
throw new \Exception("Failed to read JSON spec file {$file}");
196176
}
197177
$i = 0;
198178
$json = json_decode($contents, true);
@@ -254,10 +234,14 @@ private static function evalNestedCode(array &$data): void
254234
}
255235
}
256236

257-
private static function patchSafeString(string $code): string
237+
private static function patchHelperCode(string $code): string
258238
{
259-
$classname = '\\DevTheorem\\Handlebars\\SafeString';
260-
return preg_replace('/ (\\\Handlebars\\\)?SafeString(\s*\(.*?\))?/', ' ' . $classname . '$2', $code)
261-
?? throw new Exception("Failed to patch SafeString in $code");
239+
$code = preg_replace('/ (\\\Handlebars\\\)?SafeString(\s*\(.*?\))?/', ' \\DevTheorem\\Handlebars\\SafeString$2', $code)
240+
?? throw new \Exception("Failed to patch SafeString in $code");
241+
$code = str_replace('Utils::createFrame(', '\DevTheorem\Handlebars\Handlebars::createFrame(', $code);
242+
$code = str_replace('$options[\'name\']', '$options->name', $code);
243+
$code = str_replace('$options[\'data\']', '$options->data', $code);
244+
$code = str_replace('$options[\'hash\']', '$options->hash', $code);
245+
return str_replace('$arguments[count($arguments)-1][\'name\'];', '$arguments[count($arguments)-1]->name;', $code);
262246
}
263247
}

tests/RegressionTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,16 @@ public static function helperProvider(): array
923923
'helpers' => ['equals' => $equals],
924924
'expected' => 'Not equal',
925925
],
926+
927+
[
928+
'desc' => 'data state when helper does not create child frame should match Handlebars.js',
929+
'template' => '{{#each foo}}{{#helper}}{{@key}} {{.}} {{@foo}}{{@root.t}}, {{/helper}} {{@key}} {{.}} {{@foo}}{{@root.t}}; {{/each}}',
930+
'helpers' => [
931+
'helper' => fn(HelperOptions $options) => $options->fn($options->scope, ['data' => ['foo' => 'bar']]),
932+
],
933+
'data' => ['t' => 'val', 'foo' => ['1st', '2nd']],
934+
'expected' => ' 1st bar, 0 1st val; 2nd bar, 1 2nd val; ',
935+
],
926936
];
927937
}
928938

0 commit comments

Comments
 (0)