Skip to content

Commit 22a5d1e

Browse files
committed
Release 2.0.0 (unfinished)
1 parent 9d42dac commit 22a5d1e

3 files changed

Lines changed: 169 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,54 @@ All notable changes to this project will be documented in this file.
33

44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

6+
## [2.0.0] Compat Mode - 2026-04-27
7+
8+
### Added
9+
- `compat` compile option to enable recursive field lookup. When set to `true`, if a template variable is not
10+
found in the current scope it will automatically be looked up in parent scopes, matching Mustache's behavior.
11+
- Official [Mustache spec](https://github.com/mustache/spec) tests are now run to verify `compat` functionality.
12+
- `partialResolver` runtime option: a `Closure(string $name): ?Closure` called lazily the first time each
13+
partial name is referenced. The result is cached for the remainder of the render, so each partial is resolved
14+
at most once per invocation. This replaces the compile-time `partialResolver` option removed in this release.
15+
16+
### Changed
17+
- Improved compiler performance and reduced memory usage by simplifying internal state.
18+
- Optimized rendering of indented partials.
19+
20+
### Removed
21+
- `partials` and `partialResolver` compile-time options. Previously, partials provided at compile time
22+
were baked into the generated PHP closure, causing each partial to be recompiled and duplicated across
23+
every template that referenced it. Partials should now be supplied when invoking a template via the
24+
`partials` or `partialResolver` runtime options.
25+
26+
**Upgrade:** if you were passing partials via `Options`, move them to the runtime options instead:
27+
```php
28+
// Before
29+
$template = Handlebars::compile($source, new Options(
30+
partials: ['footer' => '<footer>...</footer>'],
31+
partialResolver: fn($name) => loadTemplate($name),
32+
));
33+
echo $template($data);
34+
35+
// After
36+
$template = Handlebars::compile($source);
37+
echo $template($data, [
38+
'partials' => ['footer' => Handlebars::compile('<footer>...</footer>')],
39+
'partialResolver' => fn($name) => Handlebars::compile(loadTemplate($name)),
40+
]);
41+
```
42+
This change enables precompiling all partials in a directory, and then lazily importing them
43+
on first use for optimal performance. See the example in the readme.
44+
45+
### Fixed
46+
- Failure to invoke `@data` variables containing a closure when passed to `if` or `unless` helpers.
47+
- Hoisted block closures leaked into the caller's scope when a precompiled template was loaded via `include`/`require`.
48+
- Hash arguments passed to a partial were ignored when the partial was invoked in certain non-array contexts.
49+
- Block helpers returning a nested or non-list array were not stringified correctly.
50+
- Partials with literal names (`{{> true}}`, `{{> false}}`, `{{> null}}`, `{{> undefined}}`) were not
51+
resolved correctly: boolean names caused a type error, and `null`/`undefined` silently rendered nothing.
52+
53+
654
## [1.2.3] Hoisted Closures - 2026-04-10
755
### Changed
856
- Improved rendering performance by hoisting the closures for block bodies and `{{else}}` clauses,
@@ -20,14 +68,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2068
fixing numerous edge cases related to helpers and `@data` variables.
2169

2270
### Fixed
23-
- `../` expressions inside `{{else}}` blocks of `{{#if}}`, `{{#unless}}`, `{{#with}}`, and sections invoking `blockHelperMissing` resolved to the wrong context level.
24-
- A missing helper called via multi-segment path in a subexpression or `@data` variable failed to invoke `helperMissing`.
25-
- A non-function context property used as a helper (e.g. `{{foo "arg"}}` where `foo` is not a closure) incorrectly called `helperMissing` rather than throwing a distinct error.
71+
- `../` expressions inside `{{else}}` blocks of `{{#if}}`, `{{#unless}}`, `{{#with}}`,
72+
and sections invoking `blockHelperMissing` resolved to the wrong context level.
73+
- A missing helper called via a `@data` variable or multi-segment path in a subexpression failed to invoke `helperMissing`.
74+
- A non-function context property used as a helper (e.g. `{{foo "arg"}}` where `foo` is not a closure)
75+
incorrectly called `helperMissing` rather than throwing a distinct error.
2676
- No error thrown when calling a missing helper via a multi-segment path with arguments (e.g. `{{foo.bar "arg"}}`).
2777
- Closures in context data could not be used as block helpers (e.g. `{{#fn}}...{{/fn}}` where `fn` is a closure).
2878
- Closures in context data or `@data` variables failed to be passed `HelperOptions` as the last argument in certain cases.
2979
- Templates with hash arguments on complex paths (e.g. `{{foo.bar arg=val}}`) were not compiled correctly.
30-
- Closures in context data were not invoked when accessed via a multi-segment path (e.g. `{{foo.bar}}`), or via a literal path (e.g. `{{"foo"}}`) in `knownHelpersOnly` mode.
80+
- Closures in context data were not invoked when accessed via a multi-segment path (e.g. `{{foo.bar}}`),
81+
or via a literal path (e.g. `{{"foo"}}`) in `knownHelpersOnly` mode.
3182
- `@data` variables incorrectly took priority over helpers with the same name.
3283
- `knownHelpersOnly` was not enforced for `@data` expressions or complex paths used with arguments.
3384

@@ -245,6 +296,7 @@ Initial release after forking from LightnCandy 1.2.6.
245296
- HTML documentation.
246297
- Dozens of unnecessary feature flags.
247298

299+
[2.0.0]: https://github.com/devtheorem/php-handlebars/compare/v1.2.3...v2.0.0
248300
[1.2.3]: https://github.com/devtheorem/php-handlebars/compare/v1.2.2...v1.2.3
249301
[1.2.2]: https://github.com/devtheorem/php-handlebars/compare/v1.2.1...v1.2.2
250302
[1.2.1]: https://github.com/devtheorem/php-handlebars/compare/v1.2.0...v1.2.1

README.md

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,33 @@
22

33
A blazing fast, spec-compliant PHP implementation of [Handlebars](https://handlebarsjs.com).
44

5-
Originally based on [LightnCandy](https://github.com/zordius/lightncandy), but rewritten to enable
6-
full Handlebars.js compatibility without excessive feature flags or performance tradeoffs.
5+
The syntax of Handlebars is generally a superset of Mustache, so in most cases it is
6+
possible to swap out Mustache for Handlebars and continue using the same templates.
7+
8+
## Features
9+
10+
* Supports all Handlebars syntax and language features, including expressions, subexpressions, helpers,
11+
partials, hooks, `@data` variables, whitespace control, and `.length` on arrays.
12+
* Templates are parsed using [PHP Handlebars Parser](https://github.com/devtheorem/php-handlebars-parser),
13+
which implements the same lexical analysis and AST grammar specification as Handlebars.js.
14+
* Tested against the [Handlebars.js spec](https://github.com/jbboehr/handlebars-spec)
15+
and the [Mustache spec](https://github.com/mustache/spec).
16+
17+
## Performance
18+
19+
PHP Handlebars started as a fork of [LightnCandy](https://github.com/zordius/lightncandy),
20+
but has been rewritten with an AST-based parser and optimized runtime to enable full
21+
Handlebars.js compatibility with better performance.
722

823
PHP Handlebars compiles and executes complex templates over 40% faster than LightnCandy, with 60% lower memory usage:
924

1025
| Library | Compile time | Runtime | Total time | Peak memory usage |
1126
|--------------------|--------------|---------|------------|-------------------|
1227
| LightnCandy 1.2.6 | 5.2 ms | 2.8 ms | 8.0 ms | 5.3 MB |
13-
| PHP Handlebars 1.2 | 3.2 ms | 1.4 ms | 4.6 ms | 1.9 MB |
28+
| PHP Handlebars 1.3 | 2.9 ms | 1.4 ms | 4.3 ms | 1.8 MB |
1429

1530
_Tested on PHP 8.5 with the JIT enabled. See the `benchmark` branch to run the same test._
1631

17-
## Features
18-
19-
* Supports all Handlebars syntax and language features, including expressions, subexpressions, helpers,
20-
partials, hooks, `@data` variables, whitespace control, and `.length` on arrays.
21-
* Templates are parsed using [PHP Handlebars Parser](https://github.com/devtheorem/php-handlebars-parser),
22-
which implements the same lexical analysis and AST grammar specification as Handlebars.js.
23-
* Tested against the full [Handlebars.js spec](https://github.com/jbboehr/handlebars-spec).
24-
2532
## Installation
2633
```
2734
composer require devtheorem/php-handlebars
@@ -31,28 +38,75 @@ composer require devtheorem/php-handlebars
3138
```php
3239
use DevTheorem\Handlebars\Handlebars;
3340

34-
$template = Handlebars::compile('Hello {{name}}!');
41+
$source = <<<'HBS'
42+
<p>Hi {{user.name}}, you have {{notifications.length}} new notification(s):</p>
43+
<ul>
44+
{{#notifications}}
45+
<li>{{count}} {{message}} ({{time}})</li>
46+
{{/notifications}}
47+
</ul>
48+
HBS;
49+
50+
$data = [
51+
'user' => ['name' => 'Jane'],
52+
'notifications' => [
53+
['count' => 4, 'message' => 'new comments', 'time' => '5 min ago'],
54+
['count' => 3, 'message' => 'new followers', 'time' => '1 hr ago'],
55+
],
56+
];
3557

36-
echo $template(['name' => 'World']); // Hello World!
58+
$template = Handlebars::compile($source);
59+
echo $template($data);
60+
```
61+
62+
Output:
63+
```html
64+
<p>Hi Jane, you have 2 new notification(s):</p>
65+
<ul>
66+
<li>4 new comments (5 min ago)</li>
67+
<li>3 new followers (1 hr ago)</li>
68+
</ul>
3769
```
3870

3971
## Precompilation
40-
Templates can be pre-compiled to native PHP for later execution:
72+
73+
Templates and partials can be pre-compiled to native PHP for later execution,
74+
avoiding the overhead of parsing and compilation on each request.
75+
76+
**Build step** - compile all templates in a directory and cache the generated PHP:
4177

4278
```php
4379
use DevTheorem\Handlebars\Handlebars;
4480

45-
$code = Handlebars::precompile('<p>{{org.name}}</p>');
81+
$templateDir = 'templates';
82+
$cacheDir = 'templateCache';
4683

47-
// save the compiled code into a PHP file
48-
file_put_contents('render.php', "<?php $code");
84+
foreach (glob("$templateDir/*.hbs") ?: [] as $file) {
85+
$name = basename($file, '.hbs');
86+
$code = Handlebars::precompile(file_get_contents($file));
87+
file_put_contents("$cacheDir/$name.php", "<?php $code");
88+
}
89+
```
4990

50-
// later import the template function from the PHP file
51-
$template = require 'render.php';
91+
**Runtime** - load only the template you need, with partials resolved lazily on demand:
5292

53-
echo $template(['org' => ['name' => 'DevTheorem']]);
93+
```php
94+
$template = require 'templateCache/page.php';
95+
96+
$data = ['title' => 'My Page', 'user' => ['name' => 'Jane']];
97+
echo $template($data, [
98+
'partialResolver' => fn(string $name) => require "templateCache/$name.php",
99+
]);
54100
```
55101

102+
Each `{{> partial}}` reference triggers the resolver on first use, and the result is cached for
103+
the rest of that render. Only the partials that the page actually references are ever loaded.
104+
105+
> [!IMPORTANT]
106+
> Precompiled templates must be regenerated whenever PHP Handlebars is updated, as the generated
107+
> PHP code depends on the current version of the runtime. The build step above should be part of
108+
> a deployment process so that precompiled output does not need to be committed to source control.
109+
56110
## Compile Options
57111

58112
You can alter the template compilation by passing an `Options` instance as the second argument to `compile` or `precompile`.
@@ -70,14 +124,23 @@ echo $template(['first' => 'John']); // Error: "last" not defined
70124

71125
### Available Options
72126

127+
* `compat`: Set to `true` to enable recursive field lookup. If a template variable is not found in the current scope,
128+
it will automatically be looked up in parent scopes, matching Mustache's default behavior.
129+
130+
> [!NOTE]
131+
> Recursive lookup has a runtime cost, so it is recommended that performance-sensitive
132+
> operations should avoid `compat` mode and instead opt for explicit path references.
133+
73134
* `knownHelpers`: Associative array (`helperName => bool`) of helpers that will be registered at runtime.
74-
The compiler uses this to emit direct helper calls instead of dynamic dispatch, which is faster and required when `knownHelpersOnly` is set.
75-
Built-in helpers (`if`, `unless`, `each`, `with`, `lookup`, `log`) are pre-populated as `true` and may be excluded by setting them to `false`.
76-
Setting `if` or `unless` to `false` also disables the inline ternary optimization and allows those helpers to be overridden at runtime.
135+
The compiler uses this to emit direct helper calls instead of dynamic dispatch,
136+
which is faster and required when `knownHelpersOnly` is set.
137+
Built-in helpers (`if`, `unless`, `each`, `with`, `lookup`, `log`) are pre-populated as `true` and may be excluded
138+
by setting them to `false`. Setting `if` or `unless` to `false` also disables the inline ternary optimization and
139+
allows those helpers to be overridden at runtime.
77140

78141
* `knownHelpersOnly`: Restricts templates to only the helpers in `knownHelpers`, enabling further compile-time optimizations:
79142
block sections and bare `{{identifier}}` expressions skip the runtime helper table and use a direct context lookup,
80-
and any use of an unregistered helper throws a compile-time exception instead of falling back to dynamic dispatch.
143+
and any use of an unknown helper throws a compile-time exception instead of falling back to dynamic dispatch.
81144

82145
* `noEscape`: Set to `true` to disable HTML escaping of output.
83146

@@ -96,22 +159,23 @@ echo $template(['first' => 'John']); // Error: "last" not defined
96159
* `explicitPartialContext`: Disables implicit context for partials.
97160
When enabled, partials that are not passed a context value will execute against an empty object.
98161

99-
* `partials`: An associative array of custom partial template strings (`name => template`).
100-
101-
* `partialResolver`: A closure that will be called at compile time for any partial not found in the `partials` array,
102-
and should return a template string for it.
103-
104162
## Runtime Options
105163

106164
`Handlebars::compile` returns a closure which can be invoked as `$template($context, $options)`.
107165
The `$options` parameter takes an array of runtime options, accepting the following keys:
108166

109167
* `data`: An associative array of custom `@data` variables (e.g. `['version' => '1.0']` makes `@version` available in the template).
110168

111-
* `helpers`: An `array<string, \Closure>` of helpers to merge with the built-in helpers. Can also be used to override a built-in helper by using the same name.
169+
* `helpers`: An `array<string, Closure>` of helpers to merge with the built-in helpers.
170+
Can also be used to override a built-in helper by using the same name.
112171

113-
* `partials`: An `array<string, \Closure>` of partial closures precompiled with `Handlebars::compile`.
114-
Useful when multiple templates share the same partials, and you want to avoid recompiling them for each template.
172+
* `partials`: An `array<string, Closure>` of partials precompiled with `Handlebars::compile`.
173+
Useful for eagerly providing a known set of partials (e.g. a shared layout).
174+
175+
* `partialResolver`: A `Closure(string $name): ?Closure` called lazily when a partial is referenced
176+
but not found in the `partials` map. Should return a compiled partial closure, or `null` if the partial
177+
does not exist. The resolved closure is cached for the remainder of the render, so each partial is loaded
178+
at most once per template invocation.
115179

116180
## Custom Helpers
117181

@@ -172,8 +236,9 @@ echo $template(['my_var' => null], $runtimeOptions); // Not equal
172236
* `hasPartial(string $name): bool`: Returns `true` if a partial with the given name is registered.
173237
Useful alongside `registerPartial()` to implement lazy partial loading.
174238

175-
* `registerPartial(string $name, \Closure $partial): void`: Registers a compiled partial closure for the
176-
remainder of the render. The closure must be produced by `Handlebars::compile`.
239+
* `registerPartial(string $name, Closure $partial): void`: Registers a compiled partial closure for the
240+
remainder of the render. The closure can be produced via `Handlebars::compile`, or by importing a
241+
cached closure created with `Handlebars::precompile`.
177242

178243
> [!NOTE]
179244
> `isset($options->fn)` and `isset($options->inverse)` return `true` if the helper was called as a block,
@@ -234,6 +299,16 @@ All syntax and language features from Handlebars.js 4.7.9 should work the same i
234299
with the following exceptions:
235300

236301
* Custom Decorators have not been implemented, as they are [deprecated in Handlebars.js](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md).
237-
* The `data` and `compat` compilation options have not been implemented.
302+
* The `data` compilation option has not been implemented.
238303
* The [runtime options to control prototype access](https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access),
239304
along with the `lookupProperty()` helper option method have not been implemented, since they aren't relevant for PHP.
305+
306+
## Mustache Compatibility
307+
308+
Handlebars is largely compatible with Mustache syntax, with a few notable differences:
309+
310+
- Handlebars does not perform recursive field lookup by default.
311+
The `compat` compile option must be set to enable this behavior.
312+
- Alternative Mustache delimiters (e.g. `{{=<% %>=}}`) are not supported.
313+
- Spaces are not allowed between the opening `{{` and a command character such as `#`, `/`, or `>`.
314+
For example, `{{> partial}}` works but `{{ > partial}}` does not.

php_handlebars_template.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
Compiled 1000 times | 2.93 ms/compile | 24.0 KB code | 1.7 MB peak
2-
Executed 1000 times | 1.36 ms/render | 139.9 KB output | 1.8 MB peak
1+
Compiled 1000 times | 3.15 ms/compile | 24.0 KB code | 1.7 MB peak
2+
Executed 1000 times | 1.49 ms/render | 139.9 KB output | 1.8 MB peak
33
<?php
44
use DevTheorem\Handlebars\Runtime as LR;
55
return function (mixed $in = null, array $options = []) {
6-
$cx = LR::createContext($in, $options, []);
6+
$cx = LR::createContext($in, $options);
77
$p0 = function($cx, $in) {return ' <link rel="stylesheet" href="'.LR::escapeExpression(LR::lookupValue($in, 'url')).'" '.(!LR::isEmpty(LR::lookupValue($in, 'media')) ? 'media="'.LR::escapeExpression(LR::lookupValue($in, 'media')).'"' : '').'>
88
';};
99
$p1 = function($cx, $in) {return LR::invokePartial($cx, 'nav-item', $in, [], ' ');};

0 commit comments

Comments
 (0)