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 yhtml
1. 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)