Transform a list of H1..6 into a hierarchical structure

I have a task to convert H1 to H6 tags from a markdown file into a JavaScript hierarchy for use in a Table of Contents.

The current list is generated by AstroJS and follows this format

[{depth: 1, text: 'I am a H1'}, {depth: 2: 'I am a H2}]
.

Key Points to Note

  • The markdown content is user-generated.
  • The list may consist of a single root heading (H1 -> H2 -> H3), or
  • It may contain multiple root headings (H2 -> H3, H2 -> H3)
  • It might also include unconventional combinations of headings (H3, H2 -> H3)
  • There could be incomplete nesting levels as well (H1 -> H3 -> H6)

We are seeking a JavaScript or TypeScript example for this conversion.

Below are three scenarios based on Markdown content being processed by an AstroJS website.

Scenario 1: Single Root Heading

This set of headings maintains the SEO-friendly structure with a single H1 followed by subsequent headings.

In Markdown Format:

# Main heading
## Sub heading 1
### More info
## Sub heading 2
### Even more info
## Sub heading 3 (edge case)
##### Deep nesting

As Flat JavaScript Array:

headings = [
  { depth: 1, text: 'Main heading' },
  { depth: 2, text: 'Sub heading 1' },
  { depth: 3, text: 'More info' },
  { depth: 2, text: 'Sub heading 2' },
  { depth: 3, text: 'Even more info' },
  { depth: 2, text: 'Sub heading 3 (edge case)' },
  { depth: 6, text: 'Deep nesting' },
]

JavaScript Hierarchy Representation:

list_of_heading_heirachies = [
  { text: 'Main heading', headings: [
    { text: 'Sub heading 1', headings: [
      { text: 'More info', headings: [] },
    ] },
    { text: 'Sub heading 2', headings: [
      { text: 'Even more info', headings: [] },
    ] },
    { text: 'Sub heading 3 (edge case)', headings: [
      { text: 'Deep nesting', headings: [] },
    ] }
  ]}
]

console.log(list_of_heading_heirachies.length);
// Returns: 1

Scenario 2: Multiple Root Headings

This type of markdown, commonly found in listicle pages, includes multiple H2s as root nodes.

In Markdown Format:

## Why is it done
### Why abc
### Why xyz
## How is it done
### How reason 1
### How reason 2
#### More info
## Conclusion

As Flat JavaScript Array:

headings = [
  { depth: 2, 'Why is it done' },
  { depth: 3, 'Why abc' },
  { depth: 3, 'Why xyz' },
  { depth: 2, 'How is it done' },
  { depth: 3, 'How reason 1' },
  { depth: 3, 'How reason 2' },
  { depth: 4, 'More info' },
  { depth: 2, 'Conclusion' }
]

JavaScript Hierarchy Model:

list_of_heading_heirachies = [
  { text: 'Why is it done', headings: [
    { text: 'Why abc', headings: [] },
    { text: 'Why xyz', headings: [] },
  ] },
  { text: 'How is it done', headings: [
    { text: 'How reason 1', headings: [] },
    { text: 'How reason 2', headings: [
      { text: 'More info', headings: [] },
    ] },
  ] },
  { text: 'Conclusion', headings: [] }
]

console.log(list_of_heading_heirachies.length);
// Returns: 3

Non-Conventional Headings List

This kind of list deviates when there is metadata or breadcrumb data preceding the main content headings.

#### Home -> Blog -> Some Articles
### By Ben Hurr
#### 24th, Sep, 2022
# Some cool Article
## Why abc
### info on why
### more info on why
## How
### How we did it
## Conclusion

As Flat JavaScript Array:

headings = [
  { depth: 4, text: 'Home -> Blog -> Some Articles' },
  { depth: 3, text: 'By Ben Hurr' },
  { depth: 4, text: '24th, Sep, 2022' },
  { depth: 1, text: 'Some cool Article' },
  { depth: 2, text: 'Why abc' },
  { depth: 3, text: 'info on why' },
  { depth: 3, text: 'more info on why' },
  { depth: 2, text: 'How' },
  { depth: 3, text: 'How we did it' },
  { depth: 2, text: 'Conclusion' },
]

JavaScript Hierarchy Structure:

list_of_heading_heirachies = [
  { text: 'Home -> Blog -> Some Articles', headings: [] },
  { text: 'By Ben Hurr', headings: [
    { text: '24th, Sep, 2022', headings: [] },
  ] },
  { text: 'Some cool Article', headings: [
    { text: 'Why abc', headings: [
      { text: 'info on why', headings: [] },
      { text: 'more info on why', headings: [] },
    ] },
    { text: 'How', headings: [
      { text: 'How we did it', headings: [] },
    ] },
    { text: 'Conclusion', headings: [] },
  ] },
]

console.log(list_of_heading_heirachies.length);
// Returns: 3

Answer №1

It appears that the code provided is quite extensive for this particular issue.

One suggestion would be to organize the list of nodes into an array of nodes still available for adding children, sorted by their depth levels. We can initiate with a starting node at depth 0 and eventually retrieve the children of the initial node upon completion.

If maintaining the 'depth' properties on the nodes is not a concern, then the solution presented should suffice. Otherwise, wrapping it in a straightforward function to recursively eliminate these properties would be necessary.

Below is one version of a potential solution:

A revised version of the code snippet...
.as-console-wrapper {max-height: 100% !important; top: 0}

The key challenge lies in sustaining the current list of open arrays. This is achieved by identifying the last node in the list with a lesser depth level than the current node, appending the current node as its child, truncating the list at that point, and incorporating the current node into it. The progression of this array can be tracked as we continually add nodes based on your examples.

// Updated list illustration 
[
  {depth: 0, headings: [], text: null}
]
// Subsequent additions demonstrated...

I trust that this proposed solution is notably simpler compared to your existing approach. Nevertheless, I acknowledge not having delved deeply into your method as it appeared rather convoluted for a problem of moderate complexity.

Answer №2

After delving into Typescript, I successfully tackled this challenge.

Although I lack expertise in this language, the code functions well. A more seasoned individual may be able to optimize it further.

The objective was to transform any list of H1..6 into a Hierarchy and then filter to generate a helpful Table of Content.

Screenshot of Output

https://i.sstatic.net/wqGf5.png

Heading.ts

export default class Heading {
  text: string;
  slug: string;
  depth: number;
  sequence: number;

  parent: Heading | null;
  headings: Heading[] | null;

  constructor(text: string, slug: string, depth: number, sequence: number) {
    this.text = text;
    this.slug = slug;
    this.depth = depth;
    this.sequence = sequence;

    this.parent = null;
    this.headings = null;
  }

  add_heading(heading: Heading) {
    heading.parent = this;
    if (this.headings === null) {
      this.headings = [];
    }
    this.headings.push(heading);
  }

  root() {
    return this.depth === 0;
  }

  valid() {
    return this.depth > 0;
  }

  data() {
    let result: any = {
      text: this.text,
      slug: this.slug,
      depth: this.depth,
      sequence: this.sequence,
      headings: this.headings?.map((h) => h.data()),
    };
    return result;
  }
}

CreateToc.ts

import Heading from './heading';

export default class CreateToc {
  toc: Heading;
  current_heading: Heading;

  constructor() {
    this.toc = new Heading("*", '-', 0, -1);
    this.current_heading = this.toc;
  }

  process(astro_headings: any[]) {
    astro_headings.forEach((astro_heading, index) => {
      let heading = new Heading(
        astro_heading.text,
        astro_heading.slug,
        astro_heading.depth,
        index
      );
      this.process_heading(heading);
    });
    return this.data();
  }

  debug() {
    console.log(JSON.stringify(this.data(), null, 2));
  }

  private data() {
    return (this.toc.headings || []).map((h) => h.data());
  }

  private process_heading(heading: Heading) {
    if (heading.depth > this.current_heading.depth) {
      this.add_child_heading(heading);
    } else if (heading.depth === this.current_heading.depth) {
      this.add_sibling_heading(heading);
    } else if (heading.depth < this.current_heading.depth) {
      this.add_upstream_heading(heading);
    }
  }

  private add_child_heading(heading: Heading) {
    this.current_heading.add_heading(heading);
    this.current_heading = heading;
  }

  private add_sibling_heading(heading: Heading) {
    if (this.current_heading!.parent === null) {
      return;
    }
    this.current_heading = this.current_heading.parent;
    this.add_child_heading(heading);
  }

  private add_upstream_heading(heading: Heading) {
    while (this.current_heading.valid() && heading.depth < this.current_heading.depth) {
      if (this.current_heading.parent === null) {
        throw new Error("current_heading.parent is null");
      }

      this.current_heading = this.current_heading.parent;
    }

    // There is no upstream heading to attach to, so just add a new root node
    if (this.current_heading.root()) {
      return this.add_child_heading(heading);
    }

    this.add_sibling_heading(heading);
  }
}

Example Usage

let filterHeadings = headings
  .filter((heading) => heading.depth === 2 || heading.depth === 3)

let tocHeadings = (new CreateToc()).process(filterHeadings);

Sample Data

[
  {
    "text": "Traits for data variation",
    "slug": "traits-for-data-variation",
    "depth": 2,
    "sequence": 0,
    "headings": [
      {
        "text": "User with role flag",
        "slug": "user-with-role-flag",
        "depth": 3,
        "sequence": 1
      },
      {
        "text": "Blog article in draft mode",
        "slug": "blog-article-in-draft-mode",
        "depth": 3,
        "sequence": 2
      }
    ]
  },
  {
    "text": "Enum Traits (non ActiveRecord)",
    "slug": "enum-traits-non-activerecord",
    "depth": 2,
    "sequence": 3,
    "headings": [
      {
        "text": "#traits_for_enums",
        "slug": "traits_for_enums",
        "depth": 3,
        "sequence": 4
      }
    ]
  },
  {
    "text": "Get a list of available traits",
    "slug": "get-a-list-of-available-traits",
    "depth": 2,
    "sequence": 5,
    "headings": [
      {
        "text": "#defined_traits",
        "slug": "defined_traits",
        "depth": 3,
        "sequence": 6
      }
    ]
  },
  {
    "text": "Global traits",
    "slug": "global-traits",
    "depth": 2,
    "sequence": 7
  }
]

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Leveraging non-ionic-native Cordova plugin

I'm currently working on integrating the [This]https://github.com/louisbl/cordova-plugin-locationservices plugin into my Ionic 4 app. Since it's a non-Ionic-native plugin, what is the proper way to call its functions within my TypeScript code? ...

The HTTP request is being executed twice for some reason unknown to me

import React, {useState, useEffect} from 'react' export function UseStateExample() { // This is a named export that must be used consistently with {} when importing/exporting. const [resourceType, setResourceType] = useState(null) useEffect ...

Incorporate React Components into a traditional HTML, CSS, and JavaScript project

Can I incorporate a React Native Component (https://www.npmjs.com/package/react-native-gifted-chat) into my vanilla JavaScript project? Is it feasible to embed a React Native component within a vanilla JavaScript project? If yes, then what is the process ...

Checking the alignment of a label or element in the center of a webpage using javascript

I am new to JavaScript and was attempting to determine if an element is centered aligned by accessing its CSS properties/values. Can someone help me retrieve the CSS property to verify text alignment using JavaScript? ...

Handling every promise in an array simultaneously

I am facing a problem with my array inside Promise.all. When I try to execute a function for the last iteration of forEach loop, I notice that my count_answers variable is only being set for the last object. This can be seen in the log output; the count_an ...

The submission feature for the textarea in Javascript is not functioning properly

As someone who is new to frontend development, I am currently facing a challenge with debugging JavaScript code that involves making changes to the content of a textarea. Despite my efforts to debug using the browser console, I have yet to identify why it ...

When navigating back to the Homepage from another page, React displays the Homepage

Recently, I started learning React and following the Traversy crash course along with an additional video on React router 6+. My route setup looks like this: import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' return ( &l ...

React-dropzone experiencing delays in loading new files for readers

Is there a way to handle conditional responses from an API and assign the desired value to errorMessageUploaded? I'm looking for a solution to receive error messages from the API, but currently, the errormessageupload variable is not being set withou ...

If the item already exists within the array, I aim to replace the existing object with the new one

I am faced with a situation where I have an array of objects, and when a user selects an option, it adds a new object to the array. My goal is to write a code that can check if this new object's key already exists in one of the objects within the arra ...

Create a PDF document using HTML code stored on a separate component within an Angular 2 application

I am facing an issue with my parentComponent and childComponent setup. Specifically, I have a button called downloadPDF in the parentComponent that is supposed to trigger a PDF download from the HTML code of the childComponent without displaying the childC ...

Steps for eliminating double quotes from the start and end of a numbered value within a list

I currently have the following data: let str = "1,2,3,4" My goal is to convert it into an array like this: arr = [1,2,3,4] ...

Issue regarding retrieving the image using TypeScript from an external API

Hey developers! I'm facing an issue with importing images from an external API. I used the tag: <img src = {photos [0] .src} /> but it doesn't seem to recognize the .src property. Can anyone shed some light on how this is supposed to work? ...

Is it feasible to arrange <DIV>s based on their dates?

I encountered an issue while working with an ordering function. var $wrapper = $('#list'); $wrapper.find('.blogboxes').sort(function (a, b) { return +b.dataset.date - +a.dataset.date; }) .appendTo( $wrapper ); <script src="ht ...

Tips for designing a horseshoe-inspired meter using CSS

  I want to create a horseshoe-shaped gauge using CSS that resembles the image below:     My attempt involved creating a circle and cutting off the bottom, similar to the technique demonstrated in this fiddle: http://jsfiddle.net/Fz3Ln/12/   Here& ...

The Nuxt Vuex authentication store seems to be having trouble updating my getters

Despite the store containing the data, my getters are not updating reactively. Take a look at my code below: function initialAuthState (): AuthState { return { token: undefined, currentUser: undefined, refreshToken: undefined } } export c ...

The process of altering a grid in HTML and adding color to a single square

I am facing a challenge that I can't seem to overcome. I need to create a game using HTML, CSS, and JS. The concept involves a grid where upon entering a number into a text box, a picture of a cartoon character is displayed in a square which turns gre ...

Tips for sending a set to a directive in angular.js?

Forgive me for my confusion. I am passing the name of a collection to my directive: <ul tag-it tag-src="preview_data.preview.extract.keywords"><li>Tag 1</li><li>Tag 2</li></ul> This is how the directive is defined: a ...

Leveraging jQuery for Adding Text to a Span While Hovering and Animating it to Slide from Left to

<p class="site-description">Eating cookies is <span class="description-addition"></span>a delight</p> <script> var phrases = new Array('a sweet experience', 'so delicious', 'the best treat', ...

Exploring the potential of Raygun.io with Angular Universal

We are currently integrating Raygun.io APM into our Angular 8 app that utilizes Angular Universal. Raygun.io offers a client side javascript library, but to use it with Angular Universal, we need to create a DOM window API. This can be achieved by install ...

Extrude a face in Three.js along its respective normal vector

My goal is to extrude faces from a THREE.Geometry object, and my step-by-step approach involves: - Specifying the faces to be extruded - Extracting vertices on the outer edges - Creating a THREE.Shape with these vertices - Extruding the shape using THREE.E ...