Skip to content

Commit f64be1a

Browse files
committed
Enable dynamic partial registration in helpers
Added `hasPartial` and `registerPartial` methods to HelperOptions, which makes possible the same pattern available in Handlebars.js of checking `Handlebars.partials` and calling `Handlebars.registerPartial` inside a helper. Also fixed `isset($options->fn)` and `isset($options->inverse)` checks to align with the existence of these methods in Handlebars.js for all block helper calls. Closes #5 Resolves zordius/lightncandy#296
1 parent 6efd9e3 commit f64be1a

7 files changed

Lines changed: 189 additions & 30 deletions

File tree

src/Compiler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
472472

473473
if ($partialName !== null && !$found) {
474474
// Register the block body as a fallback partial only if no runtime partial with this name exists yet.
475-
$parts[] = "(isset(\$cx->partials[$p]) ? '' : " . self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
475+
$parts[] = "(isset(\$cx->inlinePartials[$p]) || isset(\$cx->partials[$p]) ? '' : " . self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
476476
}
477477
$parts[] = self::getRuntimeFunc('p', "\$cx, $p, $vars, '', $bodyClosure");
478478
return implode('.', $parts);

src/HelperOptions.php

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ enum Scope
1111
case Use;
1212
}
1313

14+
/**
15+
* @phpstan-import-type Template from Handlebars
16+
*/
1417
class HelperOptions
1518
{
1619
/**
@@ -21,57 +24,76 @@ class HelperOptions
2124
public function __construct(
2225
public mixed &$scope,
2326
public array &$data,
27+
private readonly RuntimeContext $cx,
2428
public readonly string $name = '',
2529
public readonly array $hash = [],
2630
public readonly int $blockParams = 0,
27-
private readonly ?RuntimeContext $cx = null,
2831
private readonly ?Closure $cb = null,
2932
private readonly ?Closure $inv = null,
3033
private readonly array $outerBlockParams = [],
3134
) {}
3235

3336
/**
34-
* Allows isset($options->fn) and isset($options->inverse) to check whether the block exists.
37+
* Returns true if a partial with the given name is registered.
38+
*/
39+
public function hasPartial(string $name): bool
40+
{
41+
return isset($this->cx->inlinePartials[$name]) || isset($this->cx->partials[$name]);
42+
}
43+
44+
/**
45+
* Registers a compiled partial closure under the given name for the remainder of the render.
46+
* Typically used alongside hasPartial() to implement lazy partial loading.
47+
* @param Template $partial
48+
*/
49+
public function registerPartial(string $name, \Closure $partial): void
50+
{
51+
$this->cx->partials[$name] = $partial;
52+
}
53+
54+
/**
55+
* Supports isset($options->fn) and isset($options->inverse), both of which return true for
56+
* any block helper call and false for inline helper calls (matching Handlebars.js behavior).
3557
*/
3658
public function __isset(string $name): bool
3759
{
38-
if ($name === 'fn') {
39-
return $this->cb !== null;
40-
} elseif ($name === 'inverse') {
41-
return $this->inv !== null;
60+
if ($name === 'fn' || $name === 'inverse') {
61+
return $this->cb !== null || $this->inv !== null;
4262
}
4363
return false;
4464
}
4565

4666
public function fn(mixed $context = Scope::Use, mixed $data = null): string
4767
{
48-
if ($this->cx === null) {
49-
throw new \Exception('fn() is not supported for inline helpers');
50-
} elseif ($this->cb === null) {
68+
if ($this->cb === null) {
69+
if ($this->inv === null) {
70+
throw new \Exception('fn() is not supported for inline helpers');
71+
}
5172
return '';
5273
}
5374
$cx = $this->cx;
5475
$scope = $this->scope;
5576

56-
// Save partials so that any {{#* inline}} partials registered inside the block body
77+
// Save inlinePartials so that any {{#* inline}} partials registered inside the block body
5778
// don't leak out after fn() returns. The spec requires inline partials to be
5879
// block-scoped. PHP copy-on-write makes this assignment cheap when no inline partials are registered.
59-
$savedPartials = $cx->partials;
80+
$savedInlinePartials = $cx->inlinePartials;
6081

6182
// Skip depths push for explicit same-context pass (equivalent to HBS.js options.fn(this))
6283
$skipDepths = $context === $scope;
6384
$resolvedContext = $skipDepths ? $scope : ($context === Scope::Use ? $scope : $context);
6485
$ret = $this->callBlock($this->cb, $resolvedContext, !$skipDepths, $data);
6586

66-
$cx->partials = $savedPartials;
87+
$cx->inlinePartials = $savedInlinePartials;
6788
return $ret;
6889
}
6990

7091
public function inverse(mixed $context = null, mixed $data = null): string
7192
{
72-
if ($this->cx === null) {
73-
throw new \Exception('inverse() is not supported for inline helpers');
74-
} elseif ($this->inv === null) {
93+
if ($this->inv === null) {
94+
if ($this->cb === null) {
95+
throw new \Exception('inverse() is not supported for inline helpers');
96+
}
7597
return '';
7698
}
7799
return $this->callBlock($this->inv, $context ?? $this->scope, $context !== null, $data);
@@ -81,7 +103,6 @@ public function inverse(mixed $context = null, mixed $data = null): string
81103
private function callBlock(\Closure $closure, mixed $context, bool $pushDepths, ?array $data): string
82104
{
83105
$cx = $this->cx;
84-
assert($cx !== null);
85106
$savedFrame = null;
86107
$bpStack = null;
87108

@@ -133,12 +154,11 @@ public function iterate(array $items): string
133154
return '';
134155
}
135156
$cx = $this->cx;
136-
assert($cx !== null);
137157
$cb = $this->cb;
138158

139-
// Push depths and save partials once for the entire loop.
159+
// Push depths and save inlinePartials once for the entire loop.
140160
$cx->depths[] = $this->scope;
141-
$savedPartials = $cx->partials;
161+
$savedInlinePartials = $cx->inlinePartials;
142162

143163
$last = count($items) - 1;
144164
$ret = '';
@@ -166,7 +186,7 @@ public function iterate(array $items): string
166186

167187
$cx->frame = $outerFrame;
168188
array_pop($cx->depths);
169-
$cx->partials = $savedPartials;
189+
$cx->inlinePartials = $savedInlinePartials;
170190
return $ret;
171191
}
172192
}

src/Runtime.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,13 @@ public static function createContext(mixed $context, array $options, array $comp
181181

182182
if ($parentCx !== null) {
183183
// Partial context: reuse the parent's already-merged helpers and partials directly.
184-
// PHP copy-on-write ensures partials is only copied if in() registers a new inline partial.
184+
// PHP copy-on-write ensures inlinePartials is only copied if in() registers a new inline partial.
185185
// Inherit the parent's current frame so @index, @key, etc. remain accessible inside partials.
186186
// templateClosure will update frame['root'] to reference this partial's own data['root'].
187187
return new RuntimeContext(
188188
helpers: $parentCx->helpers,
189189
partials: $parentCx->partials,
190+
inlinePartials: $parentCx->inlinePartials,
190191
depths: $parentCx->depths,
191192
data: $root,
192193
frame: $parentCx->frame,
@@ -391,7 +392,9 @@ public static function merge(mixed $a, mixed $b): mixed
391392
*/
392393
public static function p(RuntimeContext $cx, string $name, mixed $context, array $hash, string $indent, ?\Closure $partialBlock = null): string
393394
{
394-
$fn = $name === '@partial-block' ? $cx->partialBlock : ($cx->partials[$name] ?? null);
395+
// inlinePartials (block-scoped {{#* inline}}) take precedence over partials (persistent),
396+
// mirroring Handlebars.js which checks options.partials before env.partials.
397+
$fn = $name === '@partial-block' ? $cx->partialBlock : ($cx->inlinePartials[$name] ?? $cx->partials[$name] ?? null);
395398
if ($fn === null) {
396399
throw new \Exception("The partial $name could not be found");
397400
}
@@ -448,7 +451,7 @@ public static function p(RuntimeContext $cx, string $name, mixed $context, array
448451
*/
449452
public static function in(RuntimeContext $cx, string $name, \Closure $partial): string
450453
{
451-
$cx->partials[$name] = $partial;
454+
$cx->inlinePartials[$name] = $partial;
452455
return '';
453456
}
454457

@@ -502,6 +505,7 @@ public static function hbch(RuntimeContext $cx, \Closure $helper, string $name,
502505
$positional[] = new HelperOptions(
503506
scope: $_this,
504507
data: $cx->frame,
508+
cx: $cx,
505509
name: $name,
506510
hash: $hash,
507511
);
@@ -525,10 +529,10 @@ public static function hbbch(RuntimeContext $cx, \Closure $helper, string $name,
525529
$positional[] = new HelperOptions(
526530
scope: $_this,
527531
data: $cx->frame,
532+
cx: $cx,
528533
name: $name,
529534
hash: $hash,
530535
blockParams: $blockParamCount,
531-
cx: $cx,
532536
cb: $cb,
533537
inv: $else,
534538
outerBlockParams: $outerBlockParams,

src/RuntimeContext.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ final class RuntimeContext
99
{
1010
/**
1111
* @param array<string, \Closure> $helpers
12-
* @param array<string, \Closure> $partials
12+
* @param array<string, \Closure> $partials compile-time and helper-registered partials (persistent)
13+
* @param array<string, \Closure> $inlinePartials block-scoped {{#* inline}} partials (reset on fn() return)
1314
* @param array<mixed> $depths
1415
* @param array<mixed> $data
1516
* @param array<mixed> $frame
1617
*/
1718
public function __construct(
1819
public array $helpers = [],
1920
public array $partials = [],
21+
public array $inlinePartials = [],
2022
public array $depths = [],
2123
public array $data = [],
2224
public array $frame = [],

tests/ErrorTest.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace DevTheorem\Handlebars\Test;
44

55
use DevTheorem\Handlebars\Handlebars;
6+
use DevTheorem\Handlebars\HelperOptions;
67
use DevTheorem\Handlebars\Options;
78
use PHPUnit\Framework\Attributes\DataProvider;
89
use PHPUnit\Framework\TestCase;
@@ -35,7 +36,7 @@ public function testRenderingException(
3536
]);
3637
$this->fail("Expected to throw exception: {$expected}. CODE: $php");
3738
} catch (\Exception $e) {
38-
$this->assertEquals($expected, $e->getMessage(), $php);
39+
$this->assertSame($expected, $e->getMessage(), $php);
3940
}
4041
}
4142

@@ -127,6 +128,20 @@ public static function renderErrorProvider(): array
127128
'options' => new Options(strict: true),
128129
'expected' => '"foo" not defined',
129130
],
131+
[
132+
'template' => '{{foo}}',
133+
'helpers' => [
134+
'foo' => fn(HelperOptions $options) => $options->fn(),
135+
],
136+
'expected' => 'fn() is not supported for inline helpers',
137+
],
138+
[
139+
'template' => '{{foo}}',
140+
'helpers' => [
141+
'foo' => fn(HelperOptions $options) => $options->inverse(),
142+
],
143+
'expected' => 'inverse() is not supported for inline helpers',
144+
],
130145
[
131146
'template' => '{{foo}}',
132147
'helpers' => [

tests/HandlebarsSpecTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ public function testSpecs(array $spec): void
158158
$this->fail("Should Fail: " . self::getSpecDetails($spec, $php, $helpersList) . "\n\nResult: $result");
159159
}
160160

161-
$this->assertEquals($spec['expected'], $result, self::getSpecDetails($spec, $php, $helpersList));
161+
$this->assertSame($spec['expected'], $result, self::getSpecDetails($spec, $php, $helpersList));
162162
}
163163

164164
/**

0 commit comments

Comments
 (0)