JSON to HTML Converter

JSON to HTML Converter

Suppose that instead of writing the structure of our HTML page in, well, HTML, we wanted to write it in JSON. Maybe we have a drag- n-drop interface that creates layouts and we need to create the HTML markup from these JavaScript objects. This feat is what Gutenberg is getting rants and raves about so it isn’t that far fetched of an idea.

To do that, we would need to solve one simple problem first and then add complexity onto it:

Given a single object, create a single element

This seems simple enough. In order to create a single element, we’d have to know 1) what type of element, 2) any attributes that are set on the element, and 3) what text to append into the element, if any ( we are assuming it has text as children or no children at all to simplify the problem for now. We’ll come back to that later).

To phrase our needs another way, we can say we need to transform

{
  type: 'p',
  attrs: [
    {
      key: 'id',
      value: 'text'
    }
  ],
  children: 'hello, world!'
}

into

<p id="text">hello, world!</p>

A simple solution might be

const mount = (el, parent) => parent.appendChild(el);

const createElement = ({ type, attrs, children }) => {
  const el = document.createElement(type);

  if (attrs && attrs.length) {
    attrs.forEach(({ key, value }) => el.setAttribute(key, value));
  }

  if (children) {
    const textEl = document.createTextNode(children);

    mount(textEl, el);
  }

  return el;
};

With a full example, with included transformation

<html>
  <body>
    <div id="app"></div>
  </body>
  <script>
    const mount = (el, parent) => parent.appendChild(el);

    const createElement = ({ type, attrs, children }) => {
      const el = document.createElement(type);

      if (attrs && attrs.length) {
        attrs.forEach(({ key, value }) => el.setAttribute(key, value));
      }

      if (children) {
        const textEl = document.createTextNode(children);

        mount(textEl, el);
      }

      return el;
    };

    const app = document.getElementById("app");

    const elementDescription = {
      type: "p",
      attrs: [
        {
          key: "id",
          value: "text"
        }
      ],
      children: "hello, world!"
    };

    const element = createElement(elementDescription);

    mount(element, app);
  </script>
</html>

A Little More Complex

Now, what if we have more than one children? We can simply tell our worker to append more than one child at a time!

const createElement = ({ type, attrs, children }) => {
  const el = document.createElement(type);

  if (attrs && attrs.length) {
    attrs.forEach(({ key, value }) => el.setAttribute(key, value));
  }

  if (children) {
    if (Array.isArray(children)) {
      children.forEach(child => {
        const textEl = document.createTextNode(child);

        mount(textEl, el);
      });
    } else {
      const textEl = document.createTextNode(children);

      mount(textEl, el);
    }
  }

  return el;
};

Updating our elementDescription to give it an array of children instead of a single value

const elementDescription = {
  type: "p",
  attrs: [
    {
      key: "id",
      value: "text"
    }
  ],
  children: ["hello,", " world!"]
};

yields the same visual end result but we do have two distinct TextNodes inside of the DOM, as children of the p tag.

Complexity All The Way Down

What if, instead of always being a string value, we wanted to be able to give our worker an elementDescription and have it resolve it itself? What if we wanted to create a div that had an h2 as its child?

We need to add a few more things for that. First, let’s add the ability to tell our worker that it needs to return a TextNode when we give it a string as a child

const TextEl = Symbol('text-element')
const createElement = ({ type, attrs, children }) => {
  // We have a text-only child!
  if (type === TextEl) {
    // we can just return a text node!
    return document.createTextNode(children)
  }

If we make it past that if check, we create the element the same way and add the attributes the same way. But when we get to adding the children, things get a little complex.

if (children) {
  // create a frag because why not?
  let childNode = document.createDocumentFragment()

  // if children is string,
  if (typeof children === "string") {
    // append the newly created text element
    childNode.appendChild(createElement({
      type: TextEl,
      children
    }))
  }

This tells our worker that first, because we have a child, we need to create a document fragment. There’s no reason for this, really. You could just add it to the el itself. But. Why not add another layer that could go wrong? This document fragment will be the thing that we append to the newly created element. We will see what that means below.

If our children is a simple string, we create a new node with a type of the TextEl thing that we created with Symbol. This allows us to keep the TextEl a private type that only our worker can send to itself.

The next possible value of children is an array

// If we have an array of children
if (Array.isArray(children)) {
  children
    .map(child =>
      typeof child === "string" ? { type: TextEl, children: child } : child
    )
    .forEach(node => {
      mount(createElement(node), childNode);
    });
}

We walk the array of children and for each one, if the type of the child is a string, we create a new node for it, similarly to how we created a new node for a single string children value above. If it is not a string, we assume that it is a valid node.

For each of the newly created nodes, we append it to the fragment we created.

The final thing that we allow for children is a valid node or element descriptor. That is a simple if check

if (children.type) {
  childNode.appendChild(createElement(children));
}

And finally, we close out our function by first appending the childNode to the el that we are creating and we return that el for someone else to handle

Putting it all together, our final version of mount and createElement look something like

const mount = (el, parent) => parent.appendChild(el);
const TextEl = Symbol("text-element");

const createElement = ({ type, attrs, children }) => {
  if (type === TextEl) {
    return document.createTextNode(children);
  }

  const el = document.createElement(type);

  if (attrs && attrs.length) {
    attrs.forEach(({ key, value }) => el.setAttribute(key, value));
  }

  if (children) {
    let childNode = document.createDocumentFragment();

    if (typeof children === "string") {
      mount(
        createElement({
          type: TextEl,
          children
        }),
        childNode
      );
    }

    if (Array.isArray(children)) {
      children
        .map(child =>
          typeof child === "string" ? { type: TextEl, children: child } : child
        )
        .forEach(node => {
          mount(createElement(node), childNode);
        });
    }

    if (children.type) {
      childNode.appendChild(createElement(children));
    }

    mount(childNode, el);
  }

  return el;
};

Our final test object can be something as simple as this, to show that we can handle nesting

const elementDescription = {
  type: "div",
  attrs: [
    {
      key: "id",
      value: "text"
    }
  ],
  children: {
    type: "div",
    attrs: [
      {
        key: "class",
        value: "some classes that are set"
      }
    ],
    children: [
      {
        type: "p",
        attrs: [
          {
            key: "class",
            value: "inner-text"
          }
        ],
        children: "hello"
      },
      " world!"
    ]
  }
};

Extra Credit

Now that we have the basics built, what else could we add on to this to make it more helpful? What other things could we describe inside of the elementDescription and add to the element inside of createElement?

Maybe we could add event handlers to the object

const elementDescription = {
  type: "button",
  attrs: [
    {
      key: "class",
      value: "btn priamry"
    }
  ],
  events: {
    click: event => console.log("The button was clicked!")
  }
};

How could we add those events to the newly created element?

What about patching the DOM if we update just one of the elements? What if we were live-updating what the children value was of one of the elements via a form event and we wanted to ensure that the DOM was up-to-date reflective of that?

If you follow this rabbit hole down far enough, you will find that you are rebuilding React, Angular, or Vue. This is because the core problem that those frameworks/libraries are trying to solve is one of trying to write our HTML inside of JavaScript.

Every time you write

<Button raised onClick={this.handleClick}>
  Hello!
</Button>

you are, in essence, writing some JSON object like above that gets fed to, basically, the createElement function we built above.

While those libraries/frameworks offer you far more than what we built in 5 minutes, let us not forget how simple the problem that we are trying to solve really is and how stupid the tools we are using to solve it really are.