Skip to content

Commit aaca8a9

Browse files
committed
Support precompiled partials at runtime
Resolves zordius/lightncandy#292 Resolves zordius/lightncandy#341
1 parent 95ca6a6 commit aaca8a9

5 files changed

Lines changed: 89 additions & 43 deletions

File tree

src/Compiler.php

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -86,21 +86,25 @@ public function composePHPRender(string $code): string
8686
return function (mixed \$in = null, array \$options = []) {
8787
\$helpers = $helpers;
8888
\$partials = [$partials];
89+
\$partials = array_replace(\$partials, \$options['_partials'] ?? []);
90+
foreach (\$options['partials'] ?? [] as \$name => \$p) {
91+
\$partials[\$name] = fn(RuntimeContext \$cx, mixed \$in) => \$p(\$in, ['_partials' => \$cx->partials, 'helpers' => \$cx->helpers, 'partialId' => \$cx->partialId]);
92+
}
8993
\$cx = new RuntimeContext(
9094
helpers: isset(\$options['helpers']) ? array_merge(\$helpers, \$options['helpers']) : \$helpers,
91-
partials: isset(\$options['partials']) ? array_merge(\$partials, \$options['partials']) : \$partials,
95+
partials: \$partials,
9296
data: isset(\$options['data']) ? array_merge(['root' => \$in], \$options['data']) : ['root' => \$in],
97+
partialId: \$options['partialId'] ?? 0,
9398
);
9499
\$in = &\$cx->data['root'];
95100
return '$code';
96101
};
97102
VAREND;
98103
}
99104

100-
private function compileProgram(Program $program, bool $withSp = false): string
105+
private function compileProgram(Program $program): string
101106
{
102-
$quoted = "'" . $this->compileBody($program) . "'";
103-
return $withSp ? "\$sp.$quoted" : $quoted;
107+
return "'" . $this->compileBody($program) . "'";
104108
}
105109

106110
private function accept(Node $node): string
@@ -184,9 +188,9 @@ private function BlockStatement(BlockStatement $block): string
184188
}
185189

186190
// Regular section: {{#"foo"}}...{{/"foo"}}
187-
$body = $this->compileProgram($block->program, true);
191+
$body = $this->compileProgram($block->program);
188192
$else = $this->compileElseClause($block);
189-
return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) use (&\$sp) {return $body;}$else") . ".'";
193+
return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) {return $body;}$else") . ".'";
190194
}
191195

192196
// Inverted section: {{^var}}...{{/var}}
@@ -272,11 +276,11 @@ private function compileEach(BlockStatement $block): string
272276
$var = $this->compileExpression($block->params[0]);
273277
[$bp, $bs] = $this->getProgramBlockParams($block->program);
274278

275-
$body = $block->program ? $this->compileProgramWithBlockParams($block->program, $bp, true) : "''";
279+
$body = $block->program ? $this->compileProgramWithBlockParams($block->program, $bp) : "''";
276280
$else = $this->compileElseClause($block);
277281

278282
$dv = self::getRuntimeFunc('dv', "$var, \$in");
279-
return "'." . self::getRuntimeFunc('sec', "\$cx, $dv, $bs, \$in, true, function(\$cx, \$in) use (&\$sp) {return $body;}$else") . ".'";
283+
return "'." . self::getRuntimeFunc('sec', "\$cx, $dv, $bs, \$in, true, function(\$cx, \$in) {return $body;}$else") . ".'";
280284
}
281285

282286
private function compileWith(BlockStatement $block): string
@@ -300,14 +304,14 @@ private function compileSection(BlockStatement $block): string
300304
$var = $this->compileExpression($block->path);
301305
$escapedName = $block->path instanceof PathExpression ? self::quote($block->path->original) : 'null';
302306

303-
$body = $this->compileProgramOrEmpty($block->program, true);
307+
$body = $this->compileProgramOrEmpty($block->program);
304308
$else = $this->compileElseClause($block);
305309

306310
if ($this->resolveHelper('blockHelperMissing')) {
307-
return "'." . self::getRuntimeFunc('hbbch', "\$cx, 'blockHelperMissing', [[$var],[]], \$in, false, function(\$cx, \$in) use (&\$sp) {return $body;}$else, $escapedName") . ".'";
311+
return "'." . self::getRuntimeFunc('hbbch', "\$cx, 'blockHelperMissing', [[$var],[]], \$in, false, function(\$cx, \$in) {return $body;}$else, $escapedName") . ".'";
308312
}
309313

310-
return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) use (&\$sp) {return $body;}$else") . ".'";
314+
return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) {return $body;}$else") . ".'";
311315
}
312316

313317
private function compileInvertedSection(BlockStatement $block): string
@@ -352,13 +356,13 @@ private function DecoratorBlock(BlockStatement $block): string
352356
$partialName = $this->getLiteralKeyName($firstArg);
353357
}
354358

355-
$body = $this->compileProgramOrEmpty($block->program, true);
359+
$body = $this->compileProgramOrEmpty($block->program);
356360

357361
// Register in usedPartial so {{> partialName}} can compile without error.
358362
// Do NOT add to partialCode - `in()` handles runtime registration, keeping inline partials block-scoped.
359363
$this->context->usedPartial[$partialName] = '';
360364

361-
return "'." . self::getRuntimeFunc('in', "\$cx, " . self::quote($partialName) . ", function(\$cx, \$in, \$sp) {return $body;}") . ".'";
365+
return "'." . self::getRuntimeFunc('in', "\$cx, " . self::quote($partialName) . ", function(\$cx, \$in) {return $body;}") . ".'";
362366
}
363367

364368
private function Decorator(Decorator $decorator): never
@@ -414,7 +418,7 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
414418
}
415419
}
416420

417-
$body = $this->compileProgram($statement->program, true);
421+
$body = $this->compileProgram($statement->program);
418422
$found = false;
419423

420424
if ($name instanceof PathExpression) {
@@ -443,19 +447,18 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
443447

444448
if (!$found) {
445449
// Register fallback body as the partial
446-
$func = "function (\$cx, \$in, \$sp) {return $body;}";
450+
$func = "function (\$cx, \$in) {return $body;}";
447451
$this->context->usedPartial[$partialName] = '';
448452
$this->context->partialCode[$partialName] = self::quote($partialName) . " => $func";
449453
}
450454
}
451455

452456
$vars = $this->compilePartialParams($statement->params, $statement->hash);
453-
$sp = "''";
454457

455458
return $hoisted
456459
. "'."
457-
. self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', function(\$cx, \$in, \$sp) {return $body;}") . "."
458-
. self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, $sp") . ".'";
460+
. self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', function(\$cx, \$in) {return $body;}") . "."
461+
. self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, ''") . ".'";
459462
}
460463

461464
private function MustacheStatement(MustacheStatement $mustache): string
@@ -753,7 +756,8 @@ private function resolveAndCompilePartial(string $name): void
753756
return;
754757
}
755758

756-
throw new \Exception("The partial $name could not be found");
759+
// Partial not found at compile time; will be resolved at runtime.
760+
$this->context->usedPartial[$name] = '';
757761
}
758762

759763
/**
@@ -793,7 +797,7 @@ private function compilePartialTemplate(string $name, string $template): void
793797
$code = (new Compiler($this->parser))->compile($program, $tmpContext);
794798
$this->context->merge($tmpContext);
795799

796-
$func = "function (\$cx, \$in, \$sp) {return '$code';}";
800+
$func = "function (\$cx, \$in) {return '$code';}";
797801
$this->context->partialCode[$name] = self::quote($name) . " => $func";
798802
}
799803

@@ -893,12 +897,12 @@ private function compileElseClause(BlockStatement $block): string
893897
* Compile a block program, pushing/popping block params around the compilation.
894898
* @param string[] $bp
895899
*/
896-
private function compileProgramWithBlockParams(Program $program, array $bp, bool $withSp = false): string
900+
private function compileProgramWithBlockParams(Program $program, array $bp): string
897901
{
898902
if ($bp) {
899903
array_unshift($this->blockParamValues, $bp);
900904
}
901-
$body = $this->compileProgram($program, $withSp);
905+
$body = $this->compileProgram($program);
902906
if ($bp) {
903907
array_shift($this->blockParamValues);
904908
}
@@ -975,9 +979,9 @@ private function getProgramBlockParams(?Program $program): array
975979
return [$bp, $bs];
976980
}
977981

978-
private function compileProgramOrEmpty(?Program $program, bool $withSp = false): string
982+
private function compileProgramOrEmpty(?Program $program): string
979983
{
980-
return $program ? $this->compileProgram($program, $withSp) : "''";
984+
return $program ? $this->compileProgram($program) : "''";
981985
}
982986

983987
/**

src/Runtime.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,15 +319,15 @@ public static function p(RuntimeContext $cx, string $p, array $v, int $pid, stri
319319
$pp = ($p === '@partial-block') ? $p . ($pid > 0 ? $pid : $cx->partialId) : $p;
320320

321321
if (!isset($cx->partials[$pp])) {
322-
throw new \Exception("Runtime: the partial $p could not be found");
322+
throw new \Exception("The partial $p could not be found");
323323
}
324324

325325
$savedPartials = $cx->partials;
326326
$savedPartialId = $cx->partialId;
327327
$cx->partialId = ($p === '@partial-block') ? ($pid > 0 ? $pid : ($cx->partialId > 0 ? $cx->partialId - 1 : 0)) : $pid;
328328
$cx->partialDepth++;
329329

330-
$result = $cx->partials[$pp]($cx, static::merge($v[0][0], $v[1]), '');
330+
$result = $cx->partials[$pp]($cx, static::merge($v[0][0], $v[1]));
331331
$cx->partials = $savedPartials;
332332
$cx->partialId = $savedPartialId;
333333
$cx->partialDepth--;
@@ -361,9 +361,9 @@ public static function in(RuntimeContext $cx, string $p, \Closure $code): void
361361
// block closure runs, any {{>@partial-block}} inside it resolves to
362362
// the correct outer partial block (not partialId - 1).
363363
$outerPartialId = $cx->partialId;
364-
$cx->partials[$p] = function (RuntimeContext $cx, mixed $in, string $sp) use ($code, $outerPartialId): string {
364+
$cx->partials[$p] = function (RuntimeContext $cx, mixed $in) use ($code, $outerPartialId): string {
365365
$cx->partialId = $outerPartialId;
366-
return $code($cx, $in, $sp);
366+
return $code($cx, $in);
367367
};
368368
} else {
369369
$cx->partials[$p] = $code;

tests/ErrorTest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ public function testRenderingException(string $template, string $expected, ?Opti
3535
public static function renderErrorProvider(): array
3636
{
3737
return [
38+
[
39+
'template' => '{{>not_found}}',
40+
'expected' => "The partial not_found could not be found",
41+
],
3842
[
3943
'template' => "{{#> testPartial}}\n {{#> innerPartial}}\n {{> @partial-block}}\n {{/innerPartial}}\n{{/testPartial}}",
4044
'options' => new Options(
@@ -43,11 +47,11 @@ public static function renderErrorProvider(): array
4347
'innerPartial' => 'innerPartial -> {{> @partial-block}} <-',
4448
],
4549
),
46-
'expected' => "Runtime: the partial @partial-block could not be found",
50+
'expected' => "The partial @partial-block could not be found",
4751
],
4852
[
4953
'template' => '{{> @partial-block}}',
50-
'expected' => "Runtime: the partial @partial-block could not be found",
54+
'expected' => "The partial @partial-block could not be found",
5155
],
5256
[
5357
'template' => '{{foo.bar}}',
@@ -157,10 +161,6 @@ public static function errorProvider(): array
157161
'template' => '{{#test foo}}{{/test}}',
158162
'expected' => 'Missing helper: "test"',
159163
],
160-
[
161-
'template' => '{{>not_found}}',
162-
'expected' => "The partial not_found could not be found",
163-
],
164164
[
165165
'template' => '{{test_join (foo bar)}}',
166166
'options' => new Options(

tests/HandlebarsSpecTest.php

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,6 @@ public function testSpecs(array $spec): void
6565
$this->markTestIncomplete('Not supported case: just skip it');
6666
}
6767

68-
if (
69-
// partial as function rather than string
70-
$spec['it'] === 'rendering function partial in vm mode'
71-
) {
72-
$this->markTestIncomplete('TODO: require fix');
73-
}
74-
7568
// FIX SPEC
7669
if ($spec['it'] === 'helper block with complex lookup expression' && isset($spec['helpers']['goodbyes']['php'])) {
7770
$spec['helpers']['goodbyes']['php'] = str_replace('$options->fn();', '$options->fn([]);', $spec['helpers']['goodbyes']['php']);
@@ -107,6 +100,17 @@ public function testSpecs(array $spec): void
107100
}
108101
eval($helpersList);
109102

103+
// Convert "!code" partials (callable PHP strings) into actual callables.
104+
$partials = [];
105+
$stringPartials = [];
106+
foreach ($spec['partials'] as $name => $partial) {
107+
if (is_array($partial) && isset($partial['!code'], $partial['php'])) {
108+
$partials[$name] = eval('return ' . $partial['php'] . ';');
109+
} else {
110+
$stringPartials[$name] = $partial;
111+
}
112+
}
113+
110114
try {
111115
$knownHelpersOnly = $spec['compileOptions']['knownHelpersOnly'] ?? false;
112116
$strict = $spec['compileOptions']['strict'] ?? false;
@@ -124,7 +128,7 @@ public function testSpecs(array $spec): void
124128
explicitPartialContext: $explicitPartialContext,
125129
/** @phpstan-ignore argument.type */
126130
helpers: $helpers,
127-
partials: $spec['partials'],
131+
partials: $stringPartials,
128132
));
129133
} catch (\Exception $e) {
130134
if (isset($spec['exception'])) {
@@ -136,7 +140,9 @@ public function testSpecs(array $spec): void
136140
$renderer = Handlebars::template($php);
137141

138142
try {
139-
$ropt = [];
143+
$ropt = [
144+
'partials' => $partials,
145+
];
140146
if (is_array($spec['runtimeOptions']['data'] ?? null)) {
141147
$ropt['data'] = [];
142148
foreach ($spec['runtimeOptions']['data'] as $key => $value) {

tests/RegressionTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ public function testLog(): void
4040
ini_restore('error_log');
4141
}
4242

43+
public function testRuntimePartials(): void
44+
{
45+
// testcase from https://github.com/zordius/lightncandy/issues/292
46+
$templateString = '{{#>outer}} {{#>compiledBlock}} inner compiledBlock {{/compiledBlock}} {{>normalTemplate}} {{/outer}}';
47+
48+
$template = Handlebars::compile($templateString, new Options(
49+
partials: [
50+
'outer' => 'outer+{{#>nested}}~{{>@partial-block}}~{{/nested}}+outer-end',
51+
'nested' => 'nested={{>@partial-block}}=nested-end',
52+
],
53+
));
54+
55+
$result = $template(null, [
56+
'partials' => [
57+
'compiledBlock' => Handlebars::compile('compiledBlock !!! {{>@partial-block}} !!! compiledBlock'),
58+
'normalTemplate' => Handlebars::compile('normalTemplate'),
59+
],
60+
]);
61+
62+
$this->assertSame('outer+nested=~ compiledBlock !!! inner compiledBlock !!! compiledBlock normalTemplate ~=nested-end+outer-end', $result);
63+
64+
// testcase from https://github.com/zordius/lightncandy/issues/341
65+
$templateString = '{{#> MyPartial child}}This <b>text</b> was sent from the template to the partial.{{/MyPartial}}';
66+
$partialTemplateString = '{{name}} says: “{{> @partial-block }}”';
67+
$template = Handlebars::compile($templateString);
68+
$context = ['child' => ['name' => 'Jason']];
69+
70+
$result = $template($context, [
71+
'partials' => [
72+
'MyPartial' => Handlebars::compile($partialTemplateString),
73+
],
74+
]);
75+
76+
$this->assertSame('Jason says: “This <b>text</b> was sent from the template to the partial.”', $result);
77+
}
78+
4379
/**
4480
* @param RegIssue $issue
4581
*/

0 commit comments

Comments
 (0)