Skip to content

Generating MDX from string templates is a bad idea

Posted on:
5 min Frontend Development Discuss on HN

Introduction

I believe good documentation helps businesses (especially self-serve businesses). Moreover, it is cheap to maintain documentation when it is automatically generated from code or comments. At Cryptlex, we use Redoc to generate documentation for our REST API using a swagger.json (OpenAPI spec) file. However, I don’t quite like Redoc for the following reasons:

  1. Using the free version means living with the Redoc branding
  2. The free version is essentially a huge React app that parses the OpenAPI spec, resolves the references, transforms the parsed spec and then renders the documentation. This takes a lot of time and causes the page to crash if you have a relatively large public API.
  3. It is quite ugly (in my opinion).
  4. It prevents co-location with product documentation

A picture of a Redoc generated website for the generic petstore API

When I started working on the new documentation at Cryptlex, I started looking for alternatives to generate our Web API documentation. I evaluated Slate, Docusaurus OpenAPI Docs, Starlight, Nextra, Readme, and Mintlify which is a Nextra fork, I guess.

At the time of evaluation, Slate was not customizable enough, Docusaurus OpenAPI Docs was full of bugs, Starlight was more of an underwhelming template than a project, Nextra had no native support for generating OpenAPI documentation, and Mintlify was just starting to support OpenAPI docs. Of the evaluated solutions, only Readme met all functional and non-functional requirements I had drafted, but it was too expensive for us.

It was clear that we could not use a solution that met our budget and requirements out of the box. I decided to spend some time trying to build something on top of Nextra, purely because I was sure that I could write interactive front-end code faster in React than in Astro.

I imagined the top-level sitemap to be as follows: An incomplete sitemap for the website

Parsing the swagger

I started with hacking into our swagger.json using @apidevtools/swagger-parser. Later, I realized that I could have used @readme/openapi-parser for the added bonus of having more detailed error messages. Using @apidevtools/swagger-parser, I could easily generate tree-like structures that I would then use to generate a configuration for the navigation sidebar (_meta.json for Nextra, sidebars.js for Docusaurus), and naively, I thought I could also build the pages with ease.

I was wrong.

I found that the documentation Redoc was generating depended on some non-standard properties specific to Redoc, like ‘x-tagGroups’. This prevented me from building a sidebar navigation that would be equivalent to the one Redoc was creating without having to write a lot of code and type overrides.

import {
  AppStore,
  GroupModel,
  IMenuItem,
  OperationModel,
  isAbsoluteUrl,
  loadAndBundleSpec,
} from "redoc";

This could have been a possible opportunity to backtrack and think of generating a swagger.json that would work without the non-standard Redoc properties, however, I decided to look into the Redoc source code and found that they exported a majority of the functions I would need to create a navigation bar equivalent to Redoc.

function createFilesForItems(menuItems: IMenuItem[], outputDir: string, parentMetaJson: MetaJson) {
  for (const item of menuItems) {
    if (item.type === 'operation') {
      // Coerce type as per Redoc internals
      const operation = item as OperationModel;
      ...
    } else if (item.type === 'section') {
      ...
    } else if (item.type === 'tag') {
      ...
    } else if (item.type === 'group') {
      ...
    } else {
      throw new Error(`Unknown menu item type: ${item.type}`);
    }
  }
}

I wrote a recursive function that would generate directories, MDX files, and the correct _meta.json files based on the documentation structure and I was able to replicate the structure Redoc would create.

if (item.type === "operation") {
  const operation = item as OperationModel;
}

I wasn’t particularly happy about how Redoc does type assertions internally with multiple instances using the as directive where it could just rely on the compiler.

A rabbit hole of Redoc components

Following this, I was sent down a rabbit hole of components in Redoc that are used to display different combinations and configurations of API endpoints. I’ve uploaded the project to GitHub for reference. I had seen multiple examples of Markdown generation using templating or some sort of an AST so I did not question the approach for long, not until I wrote this.

/**
 * Render Nextra Tabs Component
 * @param items
 * @param children
 * @returns
 */
export function TabsMdx(items: string[], children: (string | null)[]) {
  if (items.length !== children.length) {
    throw new Error("TabsMdx: items and children must be the same length");
  }
  if (items.length === 0 || children.length === 0) {
    return "";
  }

  return `
    import {Tab, Tabs} from 'nextra/components';

    <Tabs items={ ${JSON.stringify(items)} }>
        ${children
          .map(child => {
            if (child) {
              return `<Tab>${child}</Tab>`;
            } else {
              return "";
            }
          })
          .join(`\n`)}
    </Tabs>
    `;
}

I stopped and said to myself, ‘This is a nightmare for testing’. I can’t possibly test all use cases and the possible outcomes it could lead to, more importantly, unlike with conventionally written code, I cannot make the program fail gracefully.

Moving forward

I want to explore the possibility of generating API documentation using a dynamic route approach, similar to using getStaticPaths() in Astro or Next. I tried doing this with Nextra but I was unable to.

Edit (27/11/2023) – I have noticed that Starlight and Mintlify have made significant improvements since I first evaluated it and it might be worth a try.