Use output compiled handlebars at runtime as attribute and in DOM


#1

In our codebase we use handlebars out of an API so people can change the content of the handlebars. We also use these handlebars to change the text of meta-tags, because the texts are depending on the user of the platform. For example we want to say Robert Jackson's page with his 5 products. The handlebars in the API will return something like {{user.name}}'s page with his {{products.length}} products (simplified for now).

How so solve this type of problem in Ember?

What I created now is a big helper function which worked in Ember.js 3.0.0 but does not anymore in Ember 3.1.3.

import { getOwner, setOwner } from '@ember/application';
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import Helper from '@ember/component/helper';
import Ember from 'ember';
const { HTMLBars: { compile } } = Ember;

// Here we store the keys with the corresponding content-element output
const stored = {};

// In Ember you can only render templates in a real component, so we do that here
export default Helper.extend({
  store: service(),

  createDomElement(key, format, template, properties) {
    // The following logic create a real DOM element
    const owner = getOwner(this);
    const component = Component.create({
      classNames: 'hide',
      layout: compile(template),
      renderer: owner.lookup('renderer:-dom')
    });
    setOwner(component, owner);
    component.setProperties(properties);

    // We wait for the component to render and than grab the innerHTML from it
    component.on('didRender', () => {

      // We add the result in the global `stored` object
      const html = component.element.innerHTML;
      stored[key] = html.replace(/\s\s+/g, ' ');
      component.destroy();

      // Ember helpers don't support Promises, so we recompute the helper which
      // will detect the element in the global `stored` object and returns that one.
      this.recompute();
    });

    // Here we append it to the body
    component.append();
  },

  // The compute function is the function Ember runs automatically
  compute(params, hash) {
    const key = hash.key;
    const format = hash.format;
    const offer = hash.offer;

    if (!offer) return;

    // We check if the key is already stored
    const storedKey = stored[key];
    if (storedKey) {
      stored[key] = null
      return storedKey;
    }

    // Here we get our template (content-element) with a certain key
    const template = this.store.peekAll('content-element').filterBy('key', key).get('firstObject.value');
    if (!template) return;

    this.createDomElement(key, format, template, {
      offer,
      product: offer.get('product'),
      project: offer.get('project')
    });
  }
});

We want to use this as a attribute of a link:

<a class="button" href={{social-url format=facebookUrl key='share_message.facebook' offer=offer}} {{action 'sendEvent' preventDefault=false}}>{{t 'share_facebook.open'}}</a>

And we want to use this in the body of our page:

{{{social-url
        key='meta.title'
        project=offer.product.project
        product=offer.product
        offer=offer}}}

And in our meta tags:

<meta name="twitter:description" content={{social-url
  key='meta.description'
  project=model.project
  product=model.offer.product
  offer=model.offer}}>

There error I get when running our current code in Ember.js 3.1.3:

Uncaught TypeError: Cannot read property 'fullName' of undefined
    at new RootComponentDefinition (vendor.js:44666)
    at InteractiveRenderer.appendTo (vendor.js:44883)
    at Class.exports.default._emberMetal.Mixin.create._Mixin$create.appendTo (vendor.js:70569)
    at Class.exports.default._emberMetal.Mixin.create._Mixin$create.append (vendor.js:70573)
    at Class.createDomElement (frontend.js:3976)
    at Class.compute (frontend.js:4023)
    at ClassBasedHelperReference.compute (vendor.js:40727)
    at ClassBasedHelperReference.value (vendor.js:40382)
    at Object.evaluate (vendor.js:29592)
    at AppendOpcodes.evaluate (vendor.js:28822)
    
RootComponentDefinition @ vendor.js:44666
appendTo @ vendor.js:44883
exports.default._emberMetal.Mixin.create._Mixin$create.appendTo @ vendor.js:70569
exports.default._emberMetal.Mixin.create._Mixin$create.append @ vendor.js:70573
createDomElement @ frontend.js:3976
compute @ frontend.js:4023
compute @ vendor.js:40727
value @ vendor.js:40382
(anonymous) @ vendor.js:29592
...

#2

HTMLBars (the syntax used by Glimmer, Ember’s rendering engine) is designed to be a compile time only templating system. There is much magic that goes into creating these compiled JS functions much of which is part of the build pipeline provided for by ember-cli. There is not much use for attempting to compile templates in a running client because the templating is highly specific to the compiled output designed for Glimmer. Although there may be unsupported hacks or ways to attempt to leverage server-side rendering (fastboot) none of these idea would be practical for a production application.

Another complication would arise if we were to conceive of a world where we could dynamically parse HTMLBars in the client code: security. The ability for user provided input to have such wide reaching taps into the base Ember internals would be a disastrous cross-scripting attack. The Glimmer engine is far to powerful to be used as a dynamic interpreter like this. That is one of the reasons why the rendering in Ember is first pre-compiled to bytecode. It knows the content is safe because the developer wrote it. Taking input from a 3rd party (even if it the 3rd party is your own API) is still a security concern.

The alternative approach to this which is also the popular advise in the industry is to develop your own dynamic templating system that is not HTMLBars. There are two options that look and act very similar to HTMLBars if that is what you’d like to keep with and they are String based instead of bytecode based: Handlebars and Mustache.

Handlebars offers more features best suited if your own API is generating the data while Mustache has a smaller surface area and usually better suited if you expect end users to input the data themselves. These solutions will essentially sandbox the templating offering better security and less bugs.

You could easily incorporate Handlebars or Mustache into Ember and have a component or service render the data you fetched from your API.


#3

It’s not impossible to run the full glimmer compiler in the browser to dynamically compile new component templates, but I agree that it’s probably overkill for this use case.

I would use handlebars for this, and call it from an Ember helper. I tested this to make sure it works and it does:

yarn add --dev handlebars ember-auto-import
// app/helpers/apply-user-template.js
import { helper } from '@ember/component/helper';
import { compile } from 'handlebars/dist/handlebars';

let compiledTemplates = new Map();

export function applyUserTemplate([template, data]) {
  if (!compiledTemplates.has(template)) {
    compiledTemplates.set(template, compile(template));
  }
  return compiledTemplates.get(template)(data);
}

export default helper(applyUserTemplate);
// Some sample data, which would really come from your API
export default Component.extend({
  theTemplate: "{{user.name}} is {{user.age}} years old",
  theData: {
    user: {
      name: "Arthur",
      age: 2
    }
  }
});
<div>{{apply-user-template theTemplate theData}}</div>

#4

@ef4 Thanks for the code example. Amazing to get this awesome help. Also thanks to @sukima!

I forgot to mention something. In the template we use ember partials which live inside the app/partials-folder (for example app/partials/my-partial/template.hbs). We also use some helpers in the template which are defined in the ember app as well:

{{! app/partials/division-text-with-to/template.hbs }}
{{#if (and offer.sellerRewardText offer.buyerRewardText)}}
  {{t 'division_text.you_get_to'}} {{offer.buyerRewardText}}
  {{t 'division_text.and_i_get_to'}} {{offer.sellerRewardText}}
{{else if offer.sellerRewardText}}
  {{t 'division_text.i_get_to'}} {{offer.sellerRewardText}}
{{else if offer.buyerRewardText}}
  {{t 'division_text.you_get_to'}} {{offer.buyerRewardText}}
{{else if offer.product.maxRewardText}}
  {{t 'division_text.we_share_to'}} {{offer.product.maxRewardText}}
{{else}}
  {{t 'division_text.we_share_the_reward'}}
{{/if}}
// app/helpers/is-equal.js
import { helper } from '@ember/component/helper';
export default helper(function(params) {
  if (params[0] && params[1]) {
    return params[0] === params[1];
  }
  return false;
});

Also the naming convention is different. For example Handlebars includes partials like this: {{> myPartial }} and ember does it like this {{partial 'partials/my-partial'}}, for this thing I could do a replace in the helper; which is fine.

Is there a way I could reuse the partials and helpers from the ember environment in Handlebars? Not having to specify the partials and helpers twice would be nice.


#5

Not in a 100% automatic way, but you can register the same helpers with both.

The default blueprint for a helper exports both the plain javascript function and the template-compatible wrapped one:

import { helper } from '@ember/component/helper';

export function yourHelper(params/*, hash*/) {
  return params;
}

export default helper(base);

So you can import { yourHelper } form './path/to/your-helper' and then do Handlebars.registerHelper('yourHelper', function(..) { return yourHelper(...); }

You may need to adjust the arguments slightly.


#6

Thanks! For a helper this works nicely.

But how can you import an .hbs-file so you can use a partial in both environments (Handlebars.js and Ember.js)?


#7

I think you ment

It’s impossible to run the full glimmer compiler in the browser …

While I can’t find a way to import the .hbs-files in the Handlebars environment I would love to know the way to use the full glimmer compiler. I also could duplicate the partials if this is too much of an overkill. What would you do?


#8

No, it is definitely possible to run the glimmer compiler in the browser. I’m pretty sure its own test suite runs in both node and the browser, and includes compilation.


#9

Wow, I was reading impossible as possible, sorry. Do you know where to start with that?


#10

I think I will give up. Did spent to much time on fighting the framework. Thanks for all the help guys! Really nice (not being sarcastic).