Deep trees

Until now, the “trees” we’ve been working with are flat lists. Now that we’ve cleanly isolated our tree wrappers into classes, let’s extend the ObjectTree and FileTree classes to support arbitrarily deep trees.

As mentioned earlier, you can think of an async tree as a tree of promises. If you have a deep FileTree (below), you are essentially holding a tree of a promises for all the files in the corresponding file system hierarchy.

Deep object trees

We rewrite the get implementation in ObjectTree.js, adding a simplistic check to see whether the value we’re passing back is a plain JavaScript object. If it is a plain object, we’ll wrap it in its own ObjectTree before returning it.

/* src/deep/ObjectTree.js */

export default class ObjectTree {
  constructor(obj) {
    this.obj = obj;
  }

  async get(key) {
    const value = this.obj[key];
    const isPlainObject =
      typeof value === "object" &&
      Object.getPrototypeOf(value) === Object.prototype;
    const isAsyncDictionary =
      typeof value?.get === "function" && typeof value?.keys === "function";
    return isPlainObject && !isAsyncDictionary
      ? new this.constructor(value)
      : value;
  }

  async keys() {
    return Object.keys(this.obj);
  }
}

Note that instead of creating new instances with new ObjectTree, we use new this.constructor. The former could work in this tutorial, but the latter is more future-proof because it supports subclassing. If you ever were to subclass ObjectTree, you’d want that subclass to spawn new instances of the same subclass, not ObjectTree.

This lets us create a deep tree:

/* src/deep/object.js */

import ObjectTree from "./ObjectTree.js";

export default new ObjectTree({
  "Alice.md": "Hello, **Alice**.",
  "Bob.md": "Hello, **Bob**.",
  "Carol.md": "Hello, **Carol**.",
  more: {
    "David.md": "Hello, **David**.",
    "Eve.md": "Hello, **Eve**.",
  },
});

which represents the deep tree

g Alice.md Hello, **Alice**. ->Alice.md Alice.md Bob.md Hello, **Bob**. ->Bob.md Bob.md Carol.md Hello, **Carol**. ->Carol.md Carol.md more ->more more more/David.md Hello, **David**. more->more/David.md David.md more/Eve.md Hello, **Eve**. more->more/Eve.md Eve.md

Deep file trees

We do something very similar in FileTree.js. Here we check to see whether the requested key corresponds to a subdirectory and, if so, wrap that in its own FileTree before returning it.

/* src/deep/FileTree.js */

import * as fs from "node:fs/promises";
import path from "node:path";

export default class FileTree {
  constructor(dirname) {
    this.dirname = path.resolve(process.cwd(), dirname);
  }

  async get(key) {
    const filePath = path.resolve(this.dirname, key);

    let stats;
    try {
      stats = await fs.stat(filePath);
    } catch (/** @type {any} */ error) {
      if (error.code === "ENOENT" /* File not found */) {
        return undefined;
      }
      throw error;
    }

    return stats.isDirectory()
      ? new this.constructor(filePath) // Return subdirectory as a tree
      : fs.readFile(filePath); // Return file contents
  }

  async keys() {
    return fs.readdir(this.dirname);
  }
}

This lets us support arbitrarily deep subfolders.

Deep function trees

By itself, the FunctionTree class doesn’t need to be updated to support deep function-backed trees. Instead, the function that’s being wrapped would need to be updated.

For this tutorial, we’ll leave the sample function in fn.js alone, but if we wanted it to define a deep tree, for certain keys it could return values that are async trees of any type.

Converting a deep tree to a plain object

Finally, we need to update our json utility. That code has a function called plain that resolves an async tree to a plain JavaScript object. To handle deep trees, we make the same isAsyncDictionary check that the transform above does to decide whether to recurse into a subtree.

/* Inside src/deep/json.js */

// Resolve an async tree to an object with string keys and string values.
async function plain(tree) {
  const result = {};
  // Get each of the values from the tree.
  for (const key of await tree.keys()) {
    const value = await tree.get(key);

    // Is the value itself an async tree node?
    const isAsyncDictionary =
      typeof value?.get === "function" && typeof value?.keys === "function";

    result[key.toString()] = isAsyncDictionary
      ? await plain(value) // Recurse into subtree.
      : value.toString();
  }
  return result;
}

From inside the src/deep directory, display a deep ObjectTree or FileTree instance from inside the src/deep directory.

$ cd ../deep
$ node json files.js
{
  "Alice.md": "Hello, **Alice**.",
  "Bob.md": "Hello, **Bob**.",
  "Carol.md": "Hello, **Carol**.",
  "more": {
    "David.md": "Hello, **David**.",
    "Eve.md": "Hello, **Eve**."
  }
}

Deep transforms

Our transformation that converts markdown to HTML needs to be updated too. After its get implementation receives a value from the inner tree, it checks to see whether that value is itself a subtree. If it is, the function applies itself to that subtree before returning it.

/* src/deep/transform.js */

import { marked } from "marked";

export default function transform(tree) {
  return {
    async get(key) {
      if (key.endsWith(".html")) {
        const markdownKey = key.replace(/\.html$/, ".md");
        const markdown = await tree.get(markdownKey);
        if (markdown) {
          return marked(markdown.toString());
        }
      } else {
        const value = await tree.get(key);

        // Is the value itself an async tree node?
        const isAsyncDictionary =
          typeof value?.get === "function" && typeof value?.keys === "function";

        return isAsyncDictionary ? transform(value) : value;
      }
    },

    async keys() {
      const markdownKeys = Array.from(await tree.keys());
      const htmlKeys = markdownKeys.map((key) => key.replace(/\.md$/, ".html"));
      return htmlKeys;
    },
  };
}

Display the result of this transformation applied to the deep object or folder tree.

$ node json htmlFiles.js
{
  "Alice.html": "<p>Hello, <strong>Alice</strong>.</p>\n",
  "Bob.html": "<p>Hello, <strong>Bob</strong>.</p>\n",
  "Carol.html": "<p>Hello, <strong>Carol</strong>.</p>\n",
  "more": {
    "David.html": "<p>Hello, <strong>David</strong>.</p>\n",
    "Eve.html": "<p>Hello, <strong>Eve</strong>.</p>\n"
  }
}

Visually this looks like:

g Alice.html <p>Hello, <strong>Alice</strong>.</p> ->Alice.html Alice.html Bob.html <p>Hello, <strong>Bob</strong>.</p> ->Bob.html Bob.html Carol.html <p>Hello, <strong>Carol</strong>.</p> ->Carol.html Carol.html more ->more more more/David.html <p>Hello, <strong>David</strong>.</p> more->more/David.html David.html more/Eve.html <p>Hello, <strong>Eve</strong>.</p> more->more/Eve.html Eve.html

So now we have a way of transforming an arbitrarily deep folder of markdown content into a corresponding deep tree of HTML content. We’re now ready to do some interesting things with this content.

 

Next: Serve a tree ยป