Creating a HTML Tag Function - Part 2
Tags: webdev, javascript, tutorial, typescript
Published: 2023-02-07 20:32:02

Don’t forget to check out the [first part](https://dev.to/gabrieljm/creating-a-html-tag-function-part-1-1eph) 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.

```tsx
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
}
```

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

```tsx
document.body.innerHTML += html`<p>Hello World</p>`
// Replace to
document.body.append(html`<p>Hello World</p>`)
```

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.

```tsx
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
}
```

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.

```tsx
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
}
```

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.

```tsx
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()
}
```

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**.

```tsx
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
}
```

An use example.

```tsx
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>'
```

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.

```tsx
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
}
```

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

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

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

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.