Creating a HTML Tag Function - Part 2

gabrieljm

Gabriel José

Posted on February 7, 2023

Creating a HTML Tag Function - Part 2

Don’t forget to check out the first part of this tutorial. In this part I’m going to talk about the change of returning a string from the html tag function to returning a Document Fragment.

To do it we must create a template element, put the html text as its innerHTML, and then get the document fragment from the content property of the template. We can simple return the Document Fragment for now.

function html(staticText: TemplateStringsArray, ...values: any[]) {
    const fullText = staticText.reduce((acc, text, index) => {
        const stringValue = getCorrectStringValue(values[index])
        return acc + text + stringValue
    }, '')

  const template = document.createElement('template')
  template.innerHTML = fullText
  const documentFragment = template.content

    return documentFragment
}
Enter fullscreen mode Exit fullscreen mode

Just by doing this you will need to replace the code to add the elements in the DOM.

document.body.innerHTML += html`<p>Hello World</p>`
// Replace to
document.body.append(html`<p>Hello World</p>`)
Enter fullscreen mode Exit fullscreen mode

If you not make this replacement, you’ll notice that will be shown [object DocumentFragment] will the page instead of the Hello World text and the same happen with elements inserted as elements.

We need a way to catch the document fragment that is been inserted in the tagged template string and insert it into the other document fragment that is going to be created, to do so we’ll use a template element with a custom attribute, a then we’ll replace it for the document fragment it self.

interface ResourceMaps {
  elementsMap: Map<string, DocumentFragment>
}

function resolveValue(value: unknown, index: number, resourceMaps: ResourceMaps) {
  if (value instanceof DocumentFragment) {
    const elementId = `el="el-${index}"`
    resourceMaps.elementsMap.set(elementId, value)

    return `<template ${elementId}></template>`
  }

  return getCorrectStringValue(value)
}

function getCorrectStringValue(value: any) {
  if (value instanceof HTMLString) {
    return value
  }

  const isUnwantedValue = value === undefined
    || value === null
    || value === false

  if (isUnwantedValue) {
    return ''
  }

  return String(value ?? '')
    .replace(/\</g, '&lt;')
    .replace(/\>/g, '&gt;')
}

function html(staticText: TemplateStringsArray, ...values: any[]) {
  const resourceMaps: ResourceMaps = {
    elementsMap: new Map()
  }

    const fullText = staticText.reduce((acc, text, index) => {
    const currentValue = values[index]
    const stringValue = resolveValue(currentValue, index, resourceMaps)
        return acc + text + stringValue
    }, '')

  const template = document.createElement('template')
  template.innerHTML = fullText
  const documentFragment = template.content

    return documentFragment
}
Enter fullscreen mode Exit fullscreen mode

If you notice, we take the document fragment and place it in a Map with a key using the index for the inserted value and then we return a string of a template element with that attribute. Since we’re building the full HTML text we need to always return a string, store the value and put some sort of placeholder to later we know where to work on. Many of the future features I’ll show you here will work the same way.

But it is not complete yet, we need to create another function to place those elements we have stored.

function placeElements(docFrag: DocumentFragment, elementsMap: ResourceMaps['elementsMap']) {
  for (const [elementId, elements] of elementsMap) {
    const placeholder = docFrag.querySelector(`[${elementId}]`)

    if (!placeholder) continue

    const parentElement = placeholder.parentElement ?? docFrag

    parentElement.replaceChild(elements, placeholder)
  }

  elementsMap.clear()
}

function html(staticText: TemplateStringsArray, ...values: any[]) {
  const resourceMaps: ResourceMaps = {
    elementsMap: new Map()
  }

    const fullText = staticText.reduce((acc, text, index) => {
    const currentValue = values[index]
    const stringValue = resolveValue(currentValue, index, resourceMaps)
        return acc + text + stringValue
    }, '')

  const template = document.createElement('template')
  template.innerHTML = fullText
  const documentFragment = template.content

  if (resourceMaps.elementsMap.size) {
    placeElements(documentFragment, resourceMaps.elementsMap)
  }

    return documentFragment
}
Enter fullscreen mode Exit fullscreen mode

By doing this you’ll notice that everything returns to work just fine. You might be thinking, “All this to work as before?”, and yes, but by doing it we can add more features that were not possible before. With that in mind, lets add support for array values.

interface ResourceMaps {
  elementsMap: Map<string, DocumentFragment | unknown[]>
}

function resolveValue(value: unknown, index: number, resourceMaps: ResourceMaps) {
  if (value instanceof DocumentFragment || Array.isArray(value)) {
    const elementId = `el="el-${index}"`
    resourceMaps.elementsMap.set(elementId, value)

    return `<template ${elementId}></template>`
  }

  return getCorrectStringValue(value)
}

function placeElements(docFrag: DocumentFragment, elementsMap: ResourceMaps['elementsMap']) {
  for (const [elementId, elements] of elementsMap) {
    const placeholder = docFrag.querySelector(`[${elementId}]`)

    if (!placeholder) continue

    const parentElement = placeholder.parentElement ?? docFrag

    if (elements instanceof DocumentFragment) {
      parentElement.replaceChild(elements, placeholder)
      continue
    }

    for (const elementOrData of elements) {
      const element = !(elementOrData instanceof Node)
        ? new Text(String(elementOrData ?? ''))
        : elementOrData

      parentElement.insertBefore(element, placeholder)
    }

    placeholder.remove()
  }

  elementsMap.clear()
}
Enter fullscreen mode Exit fullscreen mode

With a simple addition in placeElements function and the if statement in resolveValue we add support for array values. A little observation in this part, notice that is a verification if the value in the array is instance of Node and if not we create a TextNode with the string of that value, this method also prevent XSS attacks by making every string value a text node, even if it is valid HTML text. If you prefer you can use the resolveValue for every value inside the array, I personally don’t think that is necessary, even because we’ll need to prevent a collision with that element ids. But it can be done in a simple way if you want to, take the index passed to the resolveValue function and multiply it by 10 and then add to the index of the array value you are working on, and when you place the elements you will need to loop through the array values and make the replacement.

Events

Attaching events is now possible since we’re using actual elements and we can attach them with a simple implementation very similar with the one used to place elements. We’ll add a new eventsMap in the resourceMaps, verify in the code if the value is a function, use regex to verify on the already parsed html text which event it is and set it on the eventsMap.

function resolveValue(
  html: string,
  value: unknown,
  index: number,
  resourceMaps: ResourceMaps
) {
  if (value instanceof DocumentFragment || Array.isArray(value)) {
    const elementId = `el="el-${index}"`
    resourceMaps.elementsMap.set(elementId, value)

    return `<template ${elementId}></template>`
  }

  if (typeof value === 'function') {
    const match = html.match(eventRegex)

    if (match) {
      const eventName = match[1]
      const eventId = `"evt-${index}"`
      resourceMaps.eventsMap.set(`${eventName}=${eventId}`, value)
      return eventId
    }
  }

  return getCorrectStringValue(value)
}

function html(staticText: TemplateStringsArray, ...values: any[]) {
  const resourceMaps: ResourceMaps = {
    elementsMap: new Map(),
    eventsMap: new Map()
  }

    const fullText = staticText.reduce((acc, text, index) => {
    const currentValue = values[index]
    const currentHtml = acc + text
    const stringValue = resolveValue(currentHtml, currentValue, index, resourceMaps)
        return currentHtml + stringValue
    }, '')

  const template = document.createElement('template')
  template.innerHTML = fullText
  const documentFragment = template.content

  if (resourceMaps.elementsMap.size) {
    placeElements(documentFragment, resourceMaps.elementsMap)
  }

    return documentFragment
}
Enter fullscreen mode Exit fullscreen mode

An use example.

html`
    <button on-click=${() => console.log('Hi')}>Click!</button>
`

// In the eventsMap will be set like this
eventsMap.set('click="evt-0"', () => console.log('Hi'))

// And in the html text the function value will be replaced for
'<button on-click="evt-0">Click</button>'
Enter fullscreen mode Exit fullscreen mode

The prefix on- is not mandatory, it is a prefix that I choose to use to check if the attribute text is about an event and this prefix can be anything you want, like a @ or :.

Now we have an events map we can make a function similar to the placeElements but for the events.

function applyEvents(docFrag: DocumentFragment, eventsMap: ResourceMaps['eventsMap']) {
  for(const [key, eventListener] of eventsMap) {
    const element = docFrag.querySelector(`[on-${key}]`)

    if (!element) continue

    const [eventName] = key.split('=')

    element.removeAttribute(`on-${eventName}`)
    element.addEventListener(eventName, eventListener as EventListener)
  }

  eventsMap.clear()
}

function html(staticText: TemplateStringsArray, ...values: any[]) {
  const resourceMaps: ResourceMaps = {
    elementsMap: new Map(),
    eventsMap: new Map()
  }

    const fullText = staticText.reduce((acc, text, index) => {
    const currentValue = values[index]
    const currentHtml = acc + text
    const stringValue = resolveValue(currentHtml, currentValue, index, resourceMaps)
        return currentHtml + stringValue
    }, '')

  const template = document.createElement('template')
  template.innerHTML = fullText
  const documentFragment = template.content

  if (resourceMaps.elementsMap.size) {
    placeElements(documentFragment, resourceMaps.elementsMap)
  }

  if (resourceMaps.eventsMap.size) {
        applyEvents(documentFragment, resourceMaps.eventsMap)
  }

    return documentFragment
}
Enter fullscreen mode Exit fullscreen mode

With only this you already can attach events to your elements, but if someone set the event attaching outside of the element.

html`
    <button>
        on-click=${() => console.log('hi')}
    </button>
`

// With this the end html will look like this
'<button>on-click="evt-0"</button>'
Enter fullscreen mode Exit fullscreen mode

In the end of the applyEvents function all functions set on the map are removed, so the text is going to appear in the final html but will be no event pending to be attached and this can be a sign to the developer that they put the listener in the wrong place. But you can implement some code to deal with it if you want.


And that is it for this part, you have seen that some features works just fine or better just by using actual elements in the return of the html tag function. I hope you liked the content, any questions leave a comment below and see you in the next post.

💖 💪 🙅 🚩
gabrieljm
Gabriel José

Posted on February 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related