rand[om]

rand[om]

med ∩ ml

From HTML template strings to elements

Table of contents

Some time ago, I found (and used for a while) the htl library. Before that, I was using raw template literals to build HTML, and then setting some element.innerHTML = newString. htl uses template literals, but instead returns a regular DOM Node object. This makes it a lot easier to add even listeners, setting other properties, and overall manipulating the object before appending it to the current document.

I was curious to see how it was done, and it turns out it’s easier than I thought. The full library is a single index.js file. Most of the code is parsing the HTML string to support different features from their “templating-format” (or that’s what I understood), which allows doing things like:

html`<span style=${{background: "yellow"}}>It’s all yellow!</span>`

Which wouldn’t work with a regular template literal, since you would get something like this:

<span style="[object" object]>It’s all yellow!</span>

Anyway, I don’t need any of those features, back to the original task.

The trick is quite simple, they create a <template> element, set the innerHTML to the string, and extract the generated Node from it:

function renderHtml(string) {
  const template = document.createElement("template");
  template.innerHTML = string;
  return document.importNode(template.content, true);
}

This returns a document fragment. Then they have another function that extracts it. The function does other stuff (like wrapping everything in a <span> if needed). But the summary of the function is:

if (fragment.firstChild === null) return null;
if (fragment.firstChild === fragment.lastChild) return fragment.removeChild(fragment.firstChild);

So I went ahead and tried to create my own version. The html tagged template was partially adapted from yhtml1. In my version, I wrap elements inside a <div> instead of a <span> when needed.

function render(content, elem) {
  elem.innerHTML = "";
  elem.appendChild(content);
}

// https://github.com/observablehq/htl/blob/main/src/index.js
function renderHtml(string) {
  const template = document.createElement("template");
  template.innerHTML = string;
  return document.importNode(template.content, true);
}

function extractFragment(fragment) {
  if (fragment.firstChild === null) return null;
  if (fragment.firstChild === fragment.lastChild)
    return fragment.removeChild(fragment.firstChild);
  const div = document.createElement("div");
  div.appendChild(fragment);
  return div;
}


function html(strings, ...values) {
  function escapeHtmlChars(value) {
    if (value instanceof String) return value;
    if (value === 0) return value;

    const str = String(value || "").replace(
      /[<>'"]/g,
      (char) => `&#${char.charCodeAt(0)}`
    );
    return str;
  }

  // Process template values
  let processedValues = [];
  for (let i = 0, total = values.length; i < total; i++) {
    let flattenedValue = [values[i]].flat();
    let escapedParts = [];
    for (
      let j = 0, totalParts = flattenedValue.length;
      j < totalParts;
      j++
    ) {
      escapedParts.push(escapeHtmlChars(flattenedValue[j]));
    }
    processedValues.push(escapedParts.join(""));
  }

  // Combine strings and processed values
  let result = strings[0];
  for (let i = 0, total = processedValues.length; i < total; i++) {
    result += processedValues[i] + strings[i + 1];
  }

  return new String(result);
}

And this is how to use it:

extractFragment(renderHtml(html`<p id=${"text1"}>foo bar</p>`))
// Output (as a an HTMLElement):
// <p id="text1">foo bar</p>

extractFragment(renderHtml(html`<p id=${"text1"}>foo bar</p>`)) instanceof HTMLElement
// true

// wrapped in a <div>
extractFragment(renderHtml(html`hello<p id=${"text1"}>foo bar</p>`))

// Output:
// <div>hello<p id="text1">foo bar</p></div>

If you want to put the Element inside the current document, you can use the render() function:

const target = document.body
render(extractFragment(renderHtml(html`<p id=${"text1"}>foo bar</p>`)), target)