Interpolating a normal value within a tagged template string in JS

149 views Asked by At

Javascript tagged template strings like

html`<h1>hello ${name}</h1>`;

are really fantastic ways to do interesting things with each interpolated variable without them just becoming part of the string. Unlike untagged templates such as

`<h1>hello ${name}</h1>;

which if const name = 'Mary'; had been set would yield a string indistinguishable from '<h1>hello Mary</h1>'. In the tagged template, the variables are kept separately so they can be manipulated later. Lit (lit-element, lit-html) uses them extensively and normally they're great.

Sometimes, however, one would like to put in a variable as if it were normal text and not trigger the interpolation. One example would be something like:

const main_site_header_level = 'h1';
return html`<$${main_site_header_level}>hello ${name}</$${main_site_header_level}>`;

Where I'm using the (non-existent) $${variable} to indicate to perform the interpolation as if it is just a normal backquote string.

Is there any way to do something like this? It goes against the norms of what tagged literals are for, but it is occasionally very useful.

2

There are 2 answers

6
Heiko Theißen On BEST ANSWER

You seem to want two levels of template processing: Certain values are replaced "immediately", and the rest is then handed over to the tag function (html in your case).

The immediate function in the following code takes a tag function as argument and returns another tag function which passes only the "non-immediate" values to the given tag function. Instead of the (non-existing) notation $${variable}, this uses ${{immediate:variable}}.

function html(template, ...values) {
  console.log(template, ...values);
  return "";
}
function immediate(f) {
  return function(template, ...values) {
    const v = [];
    const raw = [...template.raw];
    template = [...template];
    for (let i = 0, j = 0; i < values.length; i++) {
      if (values[i] && values[i].immediate !== undefined) {
        template.splice(j, 2, template[j] + `${values[i].immediate}` + template[j + 1]);
        raw.splice(j, 2, raw[j] + `${values[i].immediate}` + raw[j + 1]);
      } else {
        v.push(values[i]);
        j++;
      }
    }
    template.raw = raw;
    return f(template, ...v);
  };
}
const main_site_header_level = 'h1';
const name = 'world';
html`<${main_site_header_level}>hello ${name}</${main_site_header_level}>`;
immediate(html)`<${{immediate:main_site_header_level}}>hello ${name}</${{immediate:main_site_header_level}}>`;

When this is executed, it outputs

[ '<', '>hello ', '</', '>' ] h1 world h1  // seen by the tag function html
[ '<h1>hello ', '</h1>' ] world  // seen by the tag function immediate(html)

(After writing this, it seems that it duplicates the author's own answer.)

1
Michael Scott Asato Cuthbert On

Unless there's some other way to do it in Javascript, the best way seems to be to decorate the original function with a wrapper to process the literal tag. Assuming the $${variable} is what's wanted, something like this seems to be the trick:

export function literal_aware_tag(func) {
    return (strings, ...values) => {
        const new_strings = [];
        const new_values = [];
        const new_raw = [];
        new_strings.raw = new_raw;
        const len = strings.length;
        let cur_str = '';
        let cur_raw = '';
        for (let i = 0; i < len - 1; i++) {
            if (strings[i].endsWith('$')) {
                cur_str += strings[i].slice(0, -1) + values[i];
                cur_raw += strings.raw[i].slice(0, -1) + values[i];
            } else {
                new_strings.push(cur_str + strings[i]);
                new_raw.push(cur_raw + strings.raw[i]);
                new_values.push(values[i]);
                cur_str = '';
                cur_raw = '';
            }
        }
        new_strings.push(cur_str + strings[len - 1]);
        new_raw.push(cur_raw + strings.raw[len - 1]);
        return func(new_strings, ...new_values);
    };
}

or in TypeScript:

declare type TemplateTag = (strings: TemplateStringsArray, ...values: any[]) => any;

export function literal_aware_tag(func: TemplateTag): TemplateTag {
    return (strings: TemplateStringsArray, ...values) => {
        const new_strings = [];
        const new_values = [];
        const new_raw = [];
        (new_strings as any).raw = new_raw;
        const len = strings.length;
        let cur_str = '';
        let cur_raw = '';
        for (let i = 0; i < len - 1; i++) {
            if (strings[i].endsWith('$')) {
                cur_str += strings[i].slice(0, -1) + values[i];
                cur_raw += strings.raw[i].slice(0, -1) + values[i];
            } else {
                new_strings.push(cur_str + strings[i]);
                new_raw.push(cur_raw + strings.raw[i]);
                new_values.push(values[i]);
                cur_str = '';
                cur_raw = '';
            }
        }
        new_strings.push(cur_str + strings[len - 1]);
        new_raw.push(cur_raw + strings.raw[len - 1]);
        return func(<TemplateStringsArray><any> new_strings, ...new_values);
    };
}

Then a using application can decorate a non-literal-aware tag with:

import {html as original_html} from 'lit';

const html = literal_aware_tag(original_html);

function my_func(tag, value) {
    return html`<$${tag}>${value}</$${tag}>`;
}

This seems to be a general solution to tagged literals.

For the specific example of Lit (lit-html) this usage will invalidate the caching functions that make Lit so fast, but there are some cases where it's worth it to interpolate literal text that cannot be put into Lit syntax. (Particularly when decorating the css tag to include house style, like a certain font-family set, that is stored in a variable once, and is not expected to change while the application is running, but avoids hardcoding it multiple times, but still allowing parts of the application that will change to run).