xp-forge / handlebars-templates

Handlebars templates for XP web frontends
1 stars 0 forks source link

Template inheritance #17

Open thekid opened 8 months ago

thekid commented 8 months ago

Solution

The typical layout makes use of inline partials as follows:

layout.handlebars:

<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{> title}}</title>
</head>
<body>
  <nav>
    {{> nav}}
  </nav>
  <main>
    {{> content}}
  </main>
  {{> scripts}}
</body>

home.handlebars:

{{#> layout}}
  {{#*inline "title"}}My title{{/inline}}
  {{#*inline "nav"}}
    My navigation
  {{/inline}}
  {{#*inline "content"}}
    My content
  {{/inline}}
  {{#*inline "scripts"}}
    <script>...</script>
  {{/inline}}
{{/layout}}

Templates using the layout must specify all inline templates, or else rendering will throw an exception. To make specifying a certain inline template optional, we can use partial blocks as follows:

   </main>
-  {{> scripts}}
+  {{#> scripts}}{{/scripts}}
 </body>

Another option is to use partial parameters, which works best for plain strings:

   <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>{{> title}}</title>
+  <title>{{#with title}}{{.}}{{else}}Default title{{/with}}</title>
 </head>

The home template would then pass this parameter via {{#> layout title="My title"}}. *On a side note, we could create a helper to shorten the with-else syntax to something along the lines of {{coalesce title "Default title"}}.

So basically, we can mimic the following:

abstract class Layout {
  public $title= 'Default title';
  public abstract function nav();
  public abstract function content();
  public function scripts() { return ''; }
}

class Home extends Layout {
  public $title= 'My title';
  public function nav() { return 'My navigation'; }
  public function content() { return 'My content'; }
  public function scripts() { return '<script>...</script>'; }
}

Limitations

However, this does not work for more than one level. For example, we cannot have some common navigation which is inherited for all pages, but then replaced by a specific one inside a page template: The common navigation would always be used due to how inline templates' scoping works.

One solution to this is to call optional templates in the common layer:

{{#*inline "nav"}}
  {{#> site-nav}}Default navigation{{/site-nav}}
{{/inline}}

This would render the Default navigation if the template does not specify a site-nav inline partial, its contents otherwise.

See also

thekid commented 8 months ago

To avoid naming clashes, one idea could be to use variables prefixed by an @ for passing

home.handlebars:

{{#> layout @title="My app"}}
  ...
{{/layout}}

layout.handlebars:

<title>{{#with @title}}{{.}}{{else}}Default title{{/with}}</title>

This notation has no special meaning here but would mimic that of https://handlebarsjs.com/api-reference/data-variables.html#data-variables - and decrease the risk of clashing with context variables or helpers.

thekid commented 8 months ago

To shorten the with-else construct, we could make use of the ^ form and simply use sections instead of the with helper as follows:

- {{#with title}}{{.}}{{else}}Default title{{/with}}
+ {{#title}}{{.}}{{^}}Default title{{/title}}

Following this, a short syntax that could be introduced might be:

{{title ^ "Default title"}}

(much like Laravel's {{ $name or 'Guest' }}, see also https://github.com/handlebars-lang/handlebars.js/issues/1146)

This currently parses into a VariableNode(name: title, options: [Lookup(^), Quoted("Default Title")]) - we could add this without changing the parser itself - we would only overwrite the write method and have it react to the ^ operator (*and possibly others).

However, this would deviate from the official Handlebars language (where the above is a parse error!) and would increase the learning curve.


Another option without the need for modifying the parser would be a coalesce helper which:

- {{#with title}}{{.}}{{else}}Default title{{/with}}
+ {{coalesce title 'Default title'}}

This helper is trivial to implement and comes in handy in a number of situations.