Skip to content

Commit 5efc90b

Browse files
committed
Support precompiled partials at runtime
Resolves zordius/lightncandy#292 Resolves zordius/lightncandy#341
1 parent ce36610 commit 5efc90b

5 files changed

Lines changed: 91 additions & 45 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
@@ -413,7 +417,7 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
413417
}
414418

415419
$name = $statement->name;
416-
$body = $this->compileProgram($statement->program, true);
420+
$body = $this->compileProgram($statement->program);
417421

418422
if ($name instanceof PathExpression) {
419423
$partialName = $name->original;
@@ -441,19 +445,18 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
441445

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

450454
$vars = $this->compilePartialParams($statement->params, $statement->hash);
451-
$sp = "''";
452455

453456
return $hoisted
454457
. "'."
455-
. self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', function(\$cx, \$in, \$sp) {return $body;}") . "."
456-
. self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, $sp") . ".'";
458+
. self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', function(\$cx, \$in) {return $body;}") . "."
459+
. self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, ''") . ".'";
457460
}
458461

459462
private function MustacheStatement(MustacheStatement $mustache): string
@@ -751,7 +754,8 @@ private function resolveAndCompilePartial(string $name): void
751754
return;
752755
}
753756

754-
throw new \Exception("The partial $name could not be found");
757+
// Partial not found at compile time; will be resolved at runtime.
758+
$this->context->usedPartial[$name] = '';
755759
}
756760

757761
/**
@@ -785,7 +789,7 @@ private function compilePartialTemplate(string $name, string $template): void
785789
$code = (new Compiler($this->parser))->compile($program, $tmpContext);
786790
$this->context->merge($tmpContext);
787791

788-
$func = "function (\$cx, \$in, \$sp) {return '$code';}";
792+
$func = "function (\$cx, \$in) {return '$code';}";
789793
$this->context->partialCode[$name] = self::quote($name) . " => $func";
790794
}
791795

@@ -885,12 +889,12 @@ private function compileElseClause(BlockStatement $block): string
885889
* Compile a block program, pushing/popping block params around the compilation.
886890
* @param string[] $bp
887891
*/
888-
private function compileProgramWithBlockParams(Program $program, array $bp, bool $withSp = false): string
892+
private function compileProgramWithBlockParams(Program $program, array $bp): string
889893
{
890894
if ($bp) {
891895
array_unshift($this->blockParamValues, $bp);
892896
}
893-
$body = $this->compileProgram($program, $withSp);
897+
$body = $this->compileProgram($program);
894898
if ($bp) {
895899
array_shift($this->blockParamValues);
896900
}
@@ -967,9 +971,9 @@ private function getProgramBlockParams(?Program $program): array
967971
return [$bp, $bs];
968972
}
969973

970-
private function compileProgramOrEmpty(?Program $program, bool $withSp = false): string
974+
private function compileProgramOrEmpty(?Program $program): string
971975
{
972-
return $program ? $this->compileProgram($program, $withSp) : "''";
976+
return $program ? $this->compileProgram($program) : "''";
973977
}
974978

975979
/**

src/Runtime.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,13 @@ 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
$savedPartialId = $cx->partialId;
326326
$cx->partialId = ($p === '@partial-block') ? ($pid > 0 ? $pid : ($cx->partialId > 0 ? $cx->partialId - 1 : 0)) : $pid;
327327

328-
$result = $cx->partials[$pp]($cx, static::merge($v[0][0], $v[1]), '');
328+
$result = $cx->partials[$pp]($cx, static::merge($v[0][0], $v[1]));
329329
$cx->partialId = $savedPartialId;
330330

331331
if ($indent !== '') {
@@ -357,9 +357,9 @@ public static function in(RuntimeContext $cx, string $p, \Closure $code): void
357357
// block closure runs, any {{>@partial-block}} inside it resolves to
358358
// the correct outer partial block (not partialId - 1).
359359
$outerPartialId = $cx->partialId;
360-
$cx->partials[$p] = function (RuntimeContext $cx, mixed $in, string $sp) use ($code, $outerPartialId): string {
360+
$cx->partials[$p] = function (RuntimeContext $cx, mixed $in) use ($code, $outerPartialId): string {
361361
$cx->partialId = $outerPartialId;
362-
return $code($cx, $in, $sp);
362+
return $code($cx, $in);
363363
};
364364
} else {
365365
$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: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,11 @@ public function testSpecs(array $spec): void
6161

6262
// Decorators are deprecated: https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md
6363
|| $spec['description'] === 'blocks - decorators'
64-
) {
65-
$this->markTestIncomplete('Not supported case: just skip it');
66-
}
67-
68-
if (
69-
// partial as function rather than string
70-
$spec['it'] === 'rendering function partial in vm mode'
7164

72-
// todo: fix
65+
// this method may be useful in JS, but not in PHP
7366
|| $spec['description'] === 'helpers - the lookupProperty-option'
7467
) {
75-
$this->markTestIncomplete('TODO: require fix');
68+
$this->markTestIncomplete('Not supported case: just skip it');
7669
}
7770

7871
// FIX SPEC
@@ -110,6 +103,17 @@ public function testSpecs(array $spec): void
110103
}
111104
eval($helpersList);
112105

106+
// Convert "!code" partials (callable PHP strings) into actual callables.
107+
$partials = [];
108+
$stringPartials = [];
109+
foreach ($spec['partials'] as $name => $partial) {
110+
if (is_array($partial) && isset($partial['!code'], $partial['php'])) {
111+
$partials[$name] = eval('return ' . $partial['php'] . ';');
112+
} else {
113+
$stringPartials[$name] = $partial;
114+
}
115+
}
116+
113117
try {
114118
$knownHelpersOnly = $spec['compileOptions']['knownHelpersOnly'] ?? false;
115119
$strict = $spec['compileOptions']['strict'] ?? false;
@@ -127,7 +131,7 @@ public function testSpecs(array $spec): void
127131
explicitPartialContext: $explicitPartialContext,
128132
/** @phpstan-ignore argument.type */
129133
helpers: $helpers,
130-
partials: $spec['partials'],
134+
partials: $stringPartials,
131135
));
132136
} catch (\Exception $e) {
133137
if (isset($spec['exception'])) {
@@ -139,7 +143,9 @@ public function testSpecs(array $spec): void
139143
$renderer = Handlebars::template($php);
140144

141145
try {
142-
$ropt = [];
146+
$ropt = [
147+
'partials' => $partials,
148+
];
143149
if (is_array($spec['runtimeOptions']['data'] ?? null)) {
144150
$ropt['data'] = [];
145151
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)