Skip to content

Latest commit

 

History

History
251 lines (184 loc) · 7.22 KB

blocks.md

File metadata and controls

251 lines (184 loc) · 7.22 KB

Custom Blocks

You can include and use your own template helpers!

In order to do so, custom blocks must be defined via options.blocks during calls to tempura.compile and/or tempura.transform.

Important: A parsing error will be thrown when a template references a custom block (eg, {{#foo}}) but has not defined an options.blocks.foo function.

Definition

All custom blocks are defined as stateless functions. They will receive an args object (see Arguments) and are expected to return a string.

For example, let's have a custom script block that's responsible for producing a valid <script/> HTML tag. It expects to receive a src and may optionally receive a type and defer value:

let options = {
  blocks: {
    script(args) {
      let { src, defer, type } = args;

      let output = `<script src="${src}"`;
      if (type) output += ` type="${type}"`;
      if (defer) output += ' defer';
      output += '></script>';

      return output;
    }
  }
});

It can then be used anywhere within a template:

{{! custom "script" block }}
{{#script src="main.js" defer=true }}

You may define async custom blocks, too.
However, you must remember to enable options.async!

import { send } from 'httpie';

let options = {
  blocks: {
    async nasdaq(args) {
      let { symbol } = args;
      let output = `<dt>${symbol}</dt>`;

      // DEMO: Send a GET request to some Stock Ticker API
      let res = await send('GET', `https://.../symbols/${symbol}`);
      output += `<dl>${res.data.price}</dl>`;

      return output;
    }
  }
});

It can then be used anywhere within a template:

<dl>
  {{#nasdaq symbol="AAPL" }}
  {{#nasdaq symbol="GOOG" }}
  {{#nasdaq symbol="MSFT" }}
</dl>

Arguments

Your template code may pass arguments to your custom blocks.

Arguments are whitespace delimited and every key must be followed by an = and some value. For example:

{{#person name="Luke" age=31 }}

Tempura will parse your blocks' arguments into an object before calling your block definition:

let render = tempura.compile('...', {
  blocks: {
    person(args) {
      let { name, age } = args;
      return `${name} is ${age} years old.`;
    }
  }
});

Argument values may be any valid JavaScript data type and may also reference other variables:

{{#expect list}}

{{#var count = list.length}}

{{#if count > 0}}
  {{#table
    items=list total=count
    max=25 sticky=true }}
{{/if}}

Note: You can write Arrays and objects inline, but it can get messy!

Invoking Blocks from Blocks

Sometimes your custom block(s) may need to reference other custom blocks. For example, let's assume a #foo block wants to print a #bar block under some condition. In other for this to work, both the foo and bar blocks need to be defined.

The naiive approach is to hoist helper function(s) and reference them in both block definintions:

function helper(value) {
  return `<bar>${value}</bar>`;
}

let blocks = {
  foo(args) {
    let output = '<foo>';
    if (args.other) output += helper(args.other);
    return output + '</foo>';
  },
  bar(args) {
    return helper(args.name);
  }
};

let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', { blocks });
render(); //=> "<foo><bar>123</bar></foo> – <bar>Alice</bar>"

While this does work and is a totally valid approach, tempura allows you to skip the helper functions & reference the other custom blocks directly. The above example can be rewritten as:

let options = {
  blocks: {
    // NOTE: `blocks` parameter === `options.blocks` object
    foo(args, blocks) {
      let output = '<foo>';
      if (args.other) {
        // Call the `bar` method directly
        output += blocks.bar({ name: args.other });
      }
      return output + '</foo>';
    },
    // NOTE: `blocks` parameter === `options.blocks` object
    bar(args, blocks) {
      return `<bar>${args.name}</bar>`;
    }
  }
};

let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', options);
render(); //=> "<foo><bar>123</bar></foo> – <bar>Alice</bar>"

Important: Notice that when the foo definition invoked blocks.bar, it had to construct a { name } object to accommodate the bar definition's expected arguments.

Recursive Blocks

As shown in the previous section, block definitions can directly reference one another. However, blocks can also invoke themselves recursively.

Important You must include your own exit condition(s) in order to avoid an infinite loop / Maximum call stack error!

In this example, a #loop directive should print its current value until 0:

let blocks = {
  loop(args, blocks) {
    let value = args.value;
    let output = String(value);
    if (value--) {
      output += " ~> " + blocks.loop({ value }, blocks);
    }
    return output;
  }
}

let render = tempura.compile('{{#loop value=3 }}', { blocks });
render(); //=> "3 ~> 2 ~> 1 ~> 0"

Compiler Blocks

If you looked through the TypeScript definitions, you may have noticed that custom blocks and the output from tempura.compile share the same Compiler interface. In other words, they both produce or expect a function with the (data, blocks?) => Promise<string> | string signaure.

This means that you can actually use and compose tempura.compile functions within your options.blocks definitions!

Let's rebuild the same example from Invoking Blocks from Blocks, but using #foo and #bar statements only:

// Declare `blocks` variable upfront, for reference.
// This is needed so that `foo` and can see `bar` block exists.
let blocks = {
  bar: tempura.compile(`
    {{#expect name}}
    <bar>{{ name }}</bar>
  `),
};

blocks.foo = tempura.compile(`
  {{#expect other}}
  <foo>
    {{#if other}}
      {{#bar name=other }}
    {{/if}}
  </foo>
`, { blocks });

let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', { blocks });
render(); //=> "<foo><bar>123</bar></foo> – <bar>Alice</bar>"

Recursive Compiler Blocks

As with normal recursive blocks, your custom block definition may still reference itself when using tempura.compile to produce a Compiler.

The "trick" is to define an options.blocks object early so that the same object can be passed into the definition's tempura.compile call. Additionally, any directives must already exist as keys in this empty object.

For example, the loop directive (from above) must be defined like so:

let blocks = {
  // PLACEHOLDER
  loop: null,
};

// Define `loop`, with `blocks` reference
blocks.loop = tempura.compile(`
  {{#expect value }}

  {{ value }}
  {{#if value-- }}
    ~> {{#loop value=value }}
  {{/if}}
`, { blocks });

let render = tempura.compile('{{#loop value=3 }}', { blocks });
render(); //=> "3 ~> 2 ~> 1 ~> 0"

This may seem a little odd at first, but this needs to happen because when the blocks.loop definition is parsed, it needs to see a loop key inside the options.blocks object. During generation, the block's key only needs to exist. The functional definition only needs to be set once the top-level render is invoked.