As already mentioned in the case-study about archetypes, the proposal for a plugin architecture and the tagger plugin, I have been investigating how plugins could extend templates without having to rely on on('didInsertElement)
and the necessity to overwrite the whole plugin. Which turned out to be simpler than I expected.
Of course, you don't want to inject any template-code into the raw-handlebars-string. That would require some ugly sed-regexp-change-and-replace-magic. On the other end of the only have the rendered DOM-objects, where we have easy queries and ways to inject elements in a very nice fashion. Unfortunately DOM-manipulations are slow but even worth this injection works follow on-top (aka) after the handlebars/emberjs rendering magic and can easily interfere with that especially if that contains state-changing-{#if ..}
-statements and {#each}
loops. A pain to try to stick up-to-date with those.
If only there was an intermediate state in which we have a Javascript-objects-based representation of the template which we could manipulate before it gets compiled into a function. Turns out there is!
Handlebars, as it turns out, does compile
templates in these three steps:
- use
Handlebars.parse
to generate an Abstract-Syntax-Tree of the template
- create the encapsulated environment and put the AST-object into it
- compile all of that with performance optimisation into a callable Javascript function.
And right there, between step 1 and 2, we have our Objects parsed from the template string into a query-able Object-tree. YAY. So I've made a little experiment in which we are overwriting the the default Handlebars.parse
function and encapsulate it and passes its output through a list of injectors, that got previously registered. They then can manipulate that AST object, inject it's own parsed-template at very precise positions or even replace any given template data.
Unfortunately the AST-API isn't very pretty nor super-handy or made for traversing. So I've build a tiny query-language on top called Handlebars AST Query Language (HAQL) in order to make my hardest position case – inject the "tags"-rendering-snippet after the H1 of the topic, which is rendered within two {#if}
-blocks – easily read- and writeable. This is what it looks like:
var patcher = Ember.TemplatePatcher;
patcher.addGeneralPatcher("061dd3942f735486fbc91b5c7dfcf7a6", function(ast, hash, str){
opts = {"shift": 1};
Ember.TemplatePatcher.insertAt(ast, "if if if-else if[2]",
'{{#if model.tags}}' +
'<div class="tagger-tags-view">' +
'<span>tagged:</span>' +
'{{#each model.tags}}' +
'<a href="/tag/{{unbound this}}" class="tagger-tag">{{this}}</a>' +
'{{/each}}' +
'</div>' +
'{{/if}}', opts);
});
The way this works is as follows: Ember.TemplatePatcher
is a new object you can register said injector at. I have currently only the "GeneralPatcher" implemented but later I'd like to also have a few more common patterns directly at the finger tips. The first parameter you pass to the registration is an md5
hash of the template, the second is a function that will be called with the AST, the Hash and the original string. If that returns a new object, it is assumed to be an AST to be used instead (which allows to create a new AST-object encapsulating the existing template in total for e.g. inside a {#if topic.articleArchetyp}...{#else}{/if}
)
The reason why this uses a hash had first an id-constrain-reason, as we from within handlebars don't know about the path or any other ID of the template and needed a way to id them for lookup – md5 is already part of our UI-toolchain, so, easy choice. But on second thought I actually like the idea a lot because as this is a rather sensitive part of the system the author should be really sure that this is the template at a certain version. With the md5 in place, you would have to add another hash for each update but that also ensure you are compatible on something where API/ABI-Versioning doesn't exist.
Coming back to the actual UI. As said, the injection isn't at an easy position and the AST does build a tree internally, so we need to traverse that tree to find the right position. Therefore insertAt doesn't only take a number as its second parameter but also a optionally a HAQL-query string – if if if-else if[2]
in this case. It means: "go into the first if, then again the first if, then the first else and then the position after the second if". And with the optional "shift" parameter I can tell it mess around that position (as – unfortunately – the tree isn't building for non-mustache-objects) making it move by another piece after the h1.
At this position it then parses the given string (with Handlebars.parse, so it could potentially also be having injections) and adds the statements behind that position and that way become part of the official to-be-compiled template that is then rendered later including all features, the context and everything (as you can see in the example code). Adding features – like rendering tags under a topic title – with just a function call and no previously to-be-defined and potentially performance-heavy hooks in core-templates.
As said adding replace and maybe wrap-in-if are other very likely APIs to be added here. The only other changes this requires in core is another, additional hook-point for plugins. As their JS-assets are currently only included after the templates have been rendered, injection isn't possible. But with just an additional "injectTemplates"-hook that is rendering through the asset pipeline right before the templates are rendered, this would be totally fine. Which is what I am currently doing, it is just not looking up any plugin-assets yet.
Any feedback? Suggestions? Thoughts?