File trees

We now have a working markdown-to-HTML system. Depending on our needs, we might be done. At this point, the markdown content is stored in a JavaScript object defined in a single JavaScript file. As discussed earlier, there are a number of other data representations and storage systems we could choose.

Which approach is best for our particular team authors might vary, but as an example let’s look at how we can transition our system to read markdown from file system folders because that’s relatively simple. The tree approach we’re taking is flexible, so we could change our mind again later.

Comparing files and objects

Before starting, let’s quickly look at both objects and files through the lens of the AsyncTree interface. In both cases, we have standard ways of getting keys — in the case of folders and files, the keys are folder and file names. And in both cases, we have a way of getting the value or content associated with a given key.

AsyncTree interface method   Object implementation   File implementation
get(key) obj[key] fs.readFile(key)
keys() Object.keys(obj) fs.readdir(dirname)

If we’re using the Node fs API, we have our choice of synchronous or asynchronous methods, but there are performance benefits to be gained by using the asynchronous API.

It’s worth noting how much of the fs API is not necessary for our task at hand. The full API has a wide range of features for comparatively obscure tasks like changing a file’s modified date, renaming a file, or creating a symbolic link. Those features are necessary for some applications — but it’s reasonable to imagine that the vast majority of users of the fs API are using just the readdir and readFile methods shown above.

Rough in the file tree

To start on our file-backed tree implementation, we’ll need to get a path to the directory that will be the root of the tree. In this case, we use some Node APIs to get the directory of a folder relative to the folder containing the JavaScript module we’re writing.

Our goal is to then return an object implementing the AsyncTree interface for that folder.

/* src/flat/files.js */

import * as fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const moduleFolder = path.dirname(fileURLToPath(import.meta.url));
const dirname = path.resolve(moduleFolder, "markdown");

export default {
  async get(key) {
    /* TODO */
  async keys() {
    /* TODO */

Get the folder’s keys

To get the keys for the folder, we’ll ask the fs.readdir API for the list of file names in that folder, then yield the names in that list.

  async keys() {
    const filenames = await fs.readdir(dirname);
    return filenames;

Get the contents of a file

To implement the get method in the AsyncTree interface, we’ll use the fs.readFile API to read the contents of the file with the indicated key/name.

  async get(key) {
    const filename = path.resolve(dirname, key);
    try {
      return await fs.readFile(filename);
    } catch (error) {
      if (error.code === "ENOENT") {
        return undefined;
      throw error;

This get method includes some error handling. The AsyncTree interface expects the get method to return undefined for an unsupported key, but the fs.readFile API will throw an exception if a file does not exist with the indicated name. To create a well-behaved async tree, we catch exceptions and, if the exception is specifically an ENOENT (file not found) exception, we return undefined.

Test the file tree

We can test this file tree, once again copying-and-pasting the tests used for the async tree object implementation:

/* src/flat/files.test.js */

import assert from "node:assert";
import test from "node:test";
import tree from "./files.js";

test("can get the keys of the tree", async () => {
  assert.deepEqual(await tree.keys(), ["", "", ""]);

test("can get the value for a key", async () => {
  const alice = await tree.get("");
  assert.equal(alice, "Hello, **Alice**.");

test("getting an unsupported key returns undefined", async () => {
  assert.equal(await tree.get("xyz"), undefined);

Run these tests to see that all test pass:

$ node files.test.js# tests 3
# pass 3
# fail 0

Display the files

Use our json utility from inside the src/flat folder to render the folder as JSON:

$ node json files.js
  "": "Hello, **Alice**.",
  "": "Hello, **Bob**.",
  "": "Hello, **Carol**."

That’s a single chunk of data now — but the keys of that object came from a directory listing, and each of those values is content from a separate file!

Observe that this is the exact same output as the object implementation:

$ node json object.js
  "": "Hello, **Alice**.",
  "": "Hello, **Bob**.",
  "": "Hello, **Carol**."

The critical bit here is that the json utility required no modification to work with the new files-based tree. We wrote the json utility to work with async trees, and the folder is just another async tree.

Transform the folder

Since our folder is now available to us in tree form, we can convert its markdown content to HTML using the transform we already wrote. We can start with the same tree+transform module we created in htmlObject.js, and just change where the tree is coming from.

/* src/flat/htmlFiles.js */

import tree from "./files.js";
import transform from "./transform.js";

export default transform(tree);

View the HTML translation of the markdown files in the markdown folder.

$ 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"

The transform function can accept any tree of markdown content, so we can switch between our object and folder tree implementations at will. If we wanted to read the markdown content from a CMS, we could create a tree implementation backed by the CMS, then directly apply the unmodified transform function to that tree.

Both our JSON utility and markdown-to-HTML transformation are completely independent of the underlying data representation and storage location.


Next: Function trees