Bayan Bennett

Help Users Find Things — Generate a Page Tree

Material UI
Next.js
React JS
Accompanying video

Although I've added a swath of features to the reference site that I'm working on, there are a few glaring omissions from the feature set. Chiefly, the absence of a mechanism for navigating between pages. The nested structure of the pages naturally points to a similar structure of navigation, a tree.

Exploring Material UI's Tree

material ui page tree

Since I'm using Material UI, it's only natural to look at their existing tree implementation. It consists of a top level <TreeView> component and can have <TreeItem> child components, which themselves can have <TreeItem> children.

<TreeView>
  <TreeItem>
    <TreeItem />
  </TreeItem>
  <TreeItem>
    <TreeItem />
    <TreeItem>
      <TreeItem />
    </TreeItem>
  </TreeItem>
</TreeView>

Each of them has several attributes (e.g. the icons, labels, and accessibility). They can be viewed here:

Array of Paths

Previously, I used the globby library to get a promise that resolves to an array of file paths. Those file paths then have their .md extensions removed and split by /. The result is an array consisting of arrays of path segments.

import { resolve, sep } from "path";
const cwd = resolve("src", "data", "JavaScript");
const filePathToPathArray = (path: string): string[] =>
  path.replace(/\.md$/, "").split(sep);
const filePathsPromise = globby("**/*.md", {
  onlyFiles: true,
  cwd,
}).then(paths => paths.map(filePathToPathArray));

Tree Approach Overview

path to object

The input is a path, which is split into an array of path segments. How do we convert this array into something that looks more like a tree? The way object properties are accessed is like the way file paths are accessed, but instead of using a / or \, a . is used. So, the structure of the tree could be an object.

Tree Code Implementation

It's possible to create a nice clean object to represent the path tree, however, I wanted to have the ability to store more than just the path information in the tree. So, I created a slightly different schema.

type PathTreeNode = {
  page: boolean;
  children: {
    [k: string]: PathTreeNode;
  };
};

Instead of each node being an object with the keys being the child nodes, I created a separate key called children, so that there was room to add more attributes, such as page (which I'll get to later).

The first step is to reduce the path arrays.

type PathArraysToTree = (pathArrays: string[][]) => PathTreeNode;

export const pathArraysToTree: PathArraysToTree = (pathArrays) =>
  pathArrays.reduce(pathArrayToNode, { page: true, children: {} });

Each iteration of pathArrays is going to consume a path array, which consists of the path segments.

type PathArrayToNode = (
  tree: PathTreeNode,
  pathArray: string[]
) => PathTreeNode;

const pathArrayToNode: PathArrayToNode = (tree, pathArray) => {
  const lastTreeNode = pathArray.reduce(segmentToNode, tree);
  lastTreeNode.page = true;
  return tree;
};

Then each segment is used to traverse the tree. If a node doesn't exist, create it.

type SegmentToNode = (parent: PathTreeNode, segment: string) => PathTreeNode;

const segmentToNode: SegmentToNode = (parent, segment) => {
  parent.children[segment] ??= { page: false, children: {} };
  return parent.children[segment];
};

Note that page is false initially. This is because as we are traversing paths, it's not certain that .md files will exist at each one of those paths. It's not something that I'm expecting will happen often, however, it is an important piece of information.

Also, since I am mutating the tree, I can return the tree in the last line of pathArrayToNode. To make this immutable, a recursive function instead of .reduce could be used.

Integration with NextJS

All that needs to be done here is to provide the path tree to the page template using getStaticProps.

// ~/src/pages/JavaScript/[...path].tsx

import { getPathTree } from "../../utils/data-path";

export const getStaticProps: GetStaticProps<
  JavaScriptPageTemplateProps,
  PathResult
> = async ({ params }) => {
  /* ... */

  const { path } = params;

  /* ... */

  const pathTree = await getPathTree();

  return { props: { /* ... */, path: ["/JavaScript", ...path], pathTree } };
};

Recursive Path Tree React Component

Aside from the Material UI boilerplate code, there's one component that is particularly interesting: a recursive React component. Just as the name suggests, a recursive React component is one that calls itself somewhere inside the component.

The first thing that's needed is to construct the path to the node in the tree. I'm passing the parent path through the component props, so all that is needed is to spread the parentPath onto an array with the last item being the current segment. This path will be used to create the link to the page (the href) and to be passed down to the children.

export const PathTreeItem: FunctionComponent<PathTreeItemProps> = ({
  path: parentPath,
  nodeChildren,
}) => (
  <>
    {Object.entries(nodeChildren).map(([segment, node]) => {
      const path = [...parentPath, segment];
      const href = path.join("/");
      const ContentComponent = createContentComponent({ href });
      return (
        <TreeItem
          key={href}
          nodeId={href}
          label={segment}
          ContentComponent={ContentComponent}
        >
          {Object.keys(node.children).length > 0 ? (
            <PathTreeItem nodeChildren={node.children} path={path} />
          ) : null}
        </TreeItem>
      );
    })}
  </>
);

A Problem I Ran Into

There's something about the following segment of code which doesn't sit well with me. It looks a bit awkward—like it could be improved.

The way that I structured my tree is that children would always have a value. This means that the entries of the children would always result an array and therefore there would be a non-null child of the PathTreeItem. I will need to re-arrange some logic so that this is not the case, but for now I'm accepting some loss of readability by peeking into the node's children and seeing if I need to create a PathTreeItem in the first place.

{Object.keys(node.children).length > 0 ? (
  <PathTreeItem nodeChildren={node.children} path={path} />
) : null}

Those who are curious may benefit from looking at the repository as I may have produced a different solution.

The Way You Code Is Always Changing

One of the wonderful things about learning to code is that: after a few months, a satisfactory solution may look terrible. That solution may even end up being completely refactored.

If a better solution isn't immediately apparent, do not obsess about it. In time, a better solution often presents itself. The key is to develop a keen sense of code smell and to remember where those smells are.

TL; DR

I converted an array of file paths into a tree and used a recursive React component to display it. That way, visitors to the site can have a straightforward way to navigate between all the pages in the site.

© 2022 Bayan Bennett