How to recursively construct an JSON hierarchy from a NodeList?

Tags: ,



Given the following input:

<dl>
  <dt>
    <h3>Title A</h3>
    <dl>
      <dt>
        <h3>Title A- A</h3>
        <dl>
          <dt><a href="#">Item</a></dt>
          <dt><a href="#">Item</a></dt>
        </dl>
      </dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt>
        <h3>Title B- A</h3>
        <dl>
          <dt><a href="#">Item</a></dt>
          <dt><a href="#">Item</a></dt>
        </dl>
      </dt>
      <dt><a href="#">Item</a></dt>
    </dl>
  </dt>
</dl>

I want to build an JSON object based on the above input:

{
  "title": "Title A",
  "children": [
    {
      "title": "Title A- A",
      "children": [
        {"title": "Item"},
        {"title": "Item"}
      ]
    },
    {"title": "Item"},
    {"title": "Item"},
    {"title": "Item"},
    {"title": "Item"},
    {
      "title": "Title B- A",
      "children": [
        {"title": "Item"},
        {"title": "Item"}
      ]
    },
    {"title": "Item"}
  ]
}

Here’s what I have tried so far:

function buildTree(node) {
    if (!node) return [];
    const h3 = node.querySelector('h3') || node.querySelector('a');
    let result = {
        title: h3.innerText,
        children: []
    };
    const array = [...node.querySelectorAll('dl')];
    if (array) {
        result.children = array.map(el => buildTree(el.querySelector('dt')));
    }
    return result;
}

The result I’m getting is different from what I expect, Here’s the result I am getting:

{
  "title": "Title A",
  "children": [
    {
      "title": "Title A",
      "children": [
        {
          "title": "Title A- A",
          "children": [
            {
              "title": "Item A- A 1",
              "children": []
            }
          ]
        },
        {
          "title": "Item A- A 1",
          "children": []
        },
        {
          "title": "Title B- A 1",
          "children": []
        }
      ]
    },
    {
      "title": "Title A- A",
      "children": [
        {
          "title": "Item A- A 1",
          "children": []
        }
      ]
    },
    {
      "title": "Item A- A 1",
      "children": []
    },
    {
      "title": "Title B- A 1",
      "children": []
    }
  ]
}

seems that some data are not there, Any idea what I might be missing?

Answer

fix html

First I would remark that you are misusing dl. From the MDN docs

The HTML <dl> element represents a description list. The element encloses a list of groups of terms (specified using the <dt> element) and descriptions (provided by <dd> elements) …

Here’s what the correct use of dl, dt, and dd would look like –

<dl>
  <dt>Title 1</dt>
  <dd>  
    <dl>
      <dt>Title 1.1</dt>
      <dd><a href="#">Item 1.1.1</a></dd>
      <dd><a href="#">Item 1.1.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.2</a></dd>
  <dd><a href="#">Item 1.3</a></dd>
  <dd><a href="#">Item 1.4</a></dd>
  <dd><a href="#">Item 1.5</a></dd>
  <dd>
    <dl>
      <dt>Title 1.6</dt>    
      <dd><a href="#">Item 1.6.1</a></dd>
      <dd><a href="#">Item 1.6.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.7</a></dd>
</dl>

Notice it matches the expected shape of your output –

{
  "title": "Title 1",
  "children": [
    {
      "title": "Title 1.1",
      "children": [
        {"title": "Item 1.1.1"},
        {"title": "Item 1.1.2"}
      ]
    },
    {"title": "Item 1.2"},
    {"title": "Item 1.3"},
    {"title": "Item 1.4"},
    {"title": "Item 1.5"},
    {
      "title": "Title 1.6",
      "children": [
        {"title": "Item 1.6.1"},
        {"title": "Item 1.6.2"}
      ]
    },
    {"title": "Item 1.7"}
  ]
}

fromHtml

If you are not willing (or able) to change the input html as described above, please see Scott’s wonderful answer. To write a program for the proposed html, I would break it into two parts. First we write fromHtml with a simple recursive form –

function fromHtml (e)
{ switch (e?.tagName)
  { case "DL":
      return Array.from(e.childNodes, fromHtml).flat()
    case "DD":
      return [ Array.from(e.childNodes, fromHtml).flat() ]
    case "DT":
    case "A":
      return e.textContent
    default:
      return []
   }
}

fromHtml(document.querySelector('dl'))

Which gives us this intermediate format –

[
  "Title 1",
  [
    "Title 1.1",
    [ "Item 1.1.1" ],
    [ "Item 1.1.2" ]
  ],
  [ "Item 1.2" ],
  [ "Item 1.3" ],
  [ "Item 1.4" ],
  [ "Item 1.5" ],
  [
    "Title 1.6",
    [ "Item 1.6.1" ],
    [ "Item 1.6.2" ]
  ],
  [ "Item 1.7" ]
]

applyLabels

Following that, I would write a separate applyLabels function which adds the title and children labels you require –

const applyLabels = ([ title, ...children ]) =>
  children.length
    ? { title, children: children.map(applyLabels) }
    : { title }
  
const result =
  applyLabels(fromHtml(document.querySelector('dl')))
{
  "title": "Title 1",
  "children": [
    {
      "title": "Title 1.1",
      "children": [
        {"title": "Item 1.1.1"},
        {"title": "Item 1.1.2"}
      ]
    },
    {"title": "Item 1.2"},
    {"title": "Item 1.3"},
    {"title": "Item 1.4"},
    {"title": "Item 1.5"},
    {
      "title": "Title 1.6",
      "children": [
        {"title": "Item 1.6.1"},
        {"title": "Item 1.6.2"}
      ]
    },
    {"title": "Item 1.7"}
  ]
}

I might suggest one final change, which guarantees all nodes in the output have a uniform shape, { title, children }. It’s a change worth noting because in this case applyLabels is easier to write and it behaves better –

const applyLabels = ([ title, ...children ]) =>
  ({ title, children: children.map(applyLabels) })

Yes, this means that deepest descendants will have an empty children: [] property, but it makes consuming the data much easier as we don’t have to null-check certain properties.


demo

Expand the snippet below to verify the results of fromHtml and applyLabels in your own browser –

function fromHtml (e)
{ switch (e?.tagName)
  { case "DL":
      return Array.from(e.childNodes, fromHtml).flat()
    case "DD":
      return [ Array.from(e.childNodes, fromHtml).flat() ]
    case "DT":
    case "A":
      return e.textContent
    default:
      return []
   }
}

const applyLabels = ([ title, ...children ]) =>
  children.length
    ? { title, children: children.map(applyLabels) }
    : { title }
  
const result =
  applyLabels(fromHtml(document.querySelector('dl')))
  
console.log(result)
<dl>
  <dt>Title 1</dt>
  <dd>  
    <dl>
      <dt>Title 1.1</dt>
      <dd><a href="#">Item 1.1.1</a></dd>
      <dd><a href="#">Item 1.1.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.2</a></dd>
  <dd><a href="#">Item 1.3</a></dd>
  <dd><a href="#">Item 1.4</a></dd>
  <dd><a href="#">Item 1.5</a></dd>
  <dd>
    <dl>
      <dt>Title 1.6</dt>    
      <dd><a href="#">Item 1.6.1</a></dd>
      <dd><a href="#">Item 1.6.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.7</a></dd>
</dl>

remarks

I’ve written hundreds of answers on the topic of recursion and data transformation and yet this is the first time I think I’ve used .flat in an essential way. I thought I had a use case in this Q&A but Scott’s comment took it from me! This answer differs because domNode.childNodes is not a true array and so Array.prototype.flatMap cannot be used. Thanks for the interesting problem.



Source: stackoverflow