r0b
Homepage

All posts

Embed JSDoc comments in an Eleventy website

You can use JSDoc to automatically generate a documentation website to fully describe the contents of your API. This works by adding special comments around your JavaScript or TypeScript code that describe the code in more detail. For JavaScript you can even describe the types to better help the people using your API.

An example JSDoc comment

/**
  It erm ... adds two numbers together
  
  @param {number} a The first number
  @param {number} b The number to add to the first number
  @returns {number} The sum of the two numbers
*/
function add(a, b) {}

JSDoc can then generate a website to showcase your API for you. Personally, I've found these generated sites hard to navigate and difficult to get to the information I need. You can create your own template but that requires learning the ins and outs of JSDoc and how it's templating works.

For a recent project, I was creating a site to demonstrate and document a design system. I wanted to reference the JSDoc I'd already written in the code, rather than duplicate it. I found the ts-morph package on GitHub which makes it easier to parse TypeScript's Abstract Syntax Tree (AST). My ideal integration was to only pull out the JSDoc comments and embed them as markdown on the site.

AST is a data structure that represents the code in a file, rather than the raw text string. It allows it to be queried and modified in-place. AST Explorer is a good tool to play around and inspect the trees generated from code files.

There are a couple of benefits to this general approach:

  1. These doc comments only need to be written once. The same comment can be seen in an IDE code completions and on the documentation website. Those comments only need to be updated in one place, so can't get out of sync.
  2. The comments are close to the code that they document. So it's easier to update them when the code the document changes. This feels in the vein of Locality, from The Unicorn Project's Five Ideals.
  3. You have complete control of how your documentation site looks and feels. It's important to properly think through documentation. I've sometimes tried "Documentation driven development" where the docs are written before any code to get a feel for how something should work from a consumer's perspective, rather than jumping into the technical implementation.

How it works

Ok you're sold, or still interested to learn more? To show how it works, we'll create a fresh Eleventy website, along with an "API" to document and hook up. In a terminal, let's scaffold the project and fetch NPM dependencies:

mkdir eleventy-jsdoc
cd eleventy-jsdoc

npm init -y
npm install @11ty/eleventy typescript ts-morph @11ty/eleventy-plugin-syntaxhighlight markdown-it slugify

All of the code is at examples/eleventy-jsdoc if you want to jump ahead see it all in one place.

Let's make our library, lib.js, this is the API we're creating and want to document. We're going to pull these JSDoc comments through into our website:

/**
  It ermm ... adds two numbers together
  
  ```js
  import { add } from "my-api"
  
  const answer = add(40, 2);
  ```
  
  @param {number} a The first number to add
  @param {number} b The number to add to the first number
  @returns {number} The sum of the two arguments
*/
export function add(a, b) {
  return a + b
}

/**
  Greet the nice person by their name
  
  ```js
  import { greet } from "my-api"
  
  const message = greet('Geoff Testington')
  ```
  
  @param {string} name The name of the person to greet
  @returns {string} The boring greeting message
 */
export function hello(name = 'General Kenobi') {
  return `Hello there, ${name}`
}

Now add an Eleventy configuration file, eleventy.config.js, to add some custom logic:

const markdownIt = require('markdown-it')
const syntaxHighlight = require('@11ty/eleventy-plugin-syntaxhighlight')
const slugify = require('slugify')

// Create our own markdown-it instance to be used in a few places
const md = markdownIt({ html: true })
md.disable('code')

// A snippet to generate some HTML for a given API export
const apiDoc = (item) => `
<div class="apiDoc">
<h3>${item.name}</h3>
${md.render(item.content)}
</div>
`

module.exports = function (eleventyConfig) {
  eleventyConfig.addPlugin(syntaxHighlight)
  eleventyConfig.setLibrary('md', md)

  // Manually watch out API so that
  eleventyConfig.addWatchTarget('./lib.js')

  // NOTE: There is a bit of a hack here in that Eleventy mutates
  // this instance so it magically gets syntax-highlighting applied.
  // This is also exploited in the `apiDoc` shortcode below.
  eleventyConfig.addFilter('md', (content) => md.render(content))

  // A shortcode to render the JSDoc comment for an export from a given entry-point
  eleventyConfig.addShortcode('apiDoc', (api, entrypoint, name) => {
    const item = api?.[entrypoint]?.[name]
    if (!item) {
      throw new Error(`Unknown API item '${name}' in '${entrypoint}'`)
    }
    return apiDoc(item)
  })

  // A filter to generate a URL-friendly slug for a text string
  eleventyConfig.addFilter('slug', (text) => slugify(text))

  // A utility to pretty-print JSON
  eleventyConfig.addFilter('json', (value) => JSON.stringify(value, null, 2))

  // Tell Eleventy to use Nunjucks
  return { markdownTemplateEngine: 'njk', htmlTemplateEngine: 'njk' }
}

Next let's create a HTML base layout for our site, _includes/html.njk, which our pages can use:

<!DOCTYPE html>
<html>
  <head>
    <title>My Fancy API</title>
    <meta charset="utf8" />
    <meta name="viewport" content="width=device-width" />
    <!-- Some base styles so raw HTML doesn't look gross -->
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"
    />
    <!-- A light-theme for our code -->
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css"
      media="(prefers-color-scheme: light)"
    />
    <!-- A dark-theme for our code -->
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"
      media="(prefers-color-scheme: dark)"
    />
    <style>
      /** Something to make our code snippets POP */
      .apiDoc { padding-left: 1em; border-left: 5px solid var(--text-bright); }
    </style>
  </head>
  <body>
    {{ content | safe }}
  </body>
</html>

And now, we can use that layout to add our first page, index.md, a basic homepage to link to our other pages:

---
layout: html.njk
---

# My Fancy API

This is the site that will tell you all about the API and how to use it.

- [Guide](/guide)
- [Docs](/docs)

Now for the first proper page, guide.md. This is a page to showcase specific bits of the API. This uses the apiDoc shortcode registered in the Eleventy config to directly embed our JSDoc comments! The shortcode needs the api data object passed to it, more on that later, along with the entry-point and the named export you want to embed.

---
layout: html.njk
---

[Home](/)

# Getting started

> This page demonstrates pulling specific api exports using the shortcode

This is a detailed guide to using the API, these methods might be useful:

{% apiDoc api, 'lib.js', 'add' %}

Once you've got the hang of that, you can try the `hello` method:

{% apiDoc api, 'lib.js', 'hello' %}

To show another use of the integration, we can make a page that enumerates the whole API to document everything, docs.md. This shows how you can do whatever you like with that api data object, if you pull in extra information from ts-morph you can access that here. For this example, it loops through each entry-point and their corresponding named exports to dump them all out into the page.

There is also a little "Debug" section so you can see what was put onto that api object.

---
layout: html.njk
---

<section>

<p><a href="/">Home</a></p>

<h1>API Docs</h1>

<p>This is all the things the API does, in alphabetical order </p>

<blockquote>This page shows how to enumerate each entry point in the API and render each export from them</blockquote>

<details>
<summary>Debug</summary>
<pre>{{ api | json }}</pre>
</details>
</section>

{% for entrypoint, items in api %}

<section>
  <h2 id="{{ entrypoint | slugify }}">{{ entrypoint }}</h2>
  
  {% for name, item in items  %}
  {% if item.content %}
  
  <div class="apiDoc">
    <h3 id="{{ name | slugify }}">{{ name }}</h3>
    
    {{ item.content | md | safe }}
  </div>
  
  {% endif %}
  {% endfor %}
</section>

{% endfor %}

That is the basic site setup, now we need to start linking it up with TypeScript with ts-morph. For that we'll need to add a TypeScript config file, tsconfig.json:

{
  "compilerOptions": {
    "allowJs": true
  }
}

To get this api object we've seen in the templates, we'll use an Eleventy global data file, _data/api.js. It runs ones to generate a data object with JavaScript. This is the brunt of the integration, there is quite a bit going on here and the TypeScript AST is quite complex.

It loads the predefined entry-points up and parses their AST nodes into memory. With those nodes we go through and find the code which is exported, i.e. code that uses JavaScript's export modifier, and then collect the JSDoc comments from them. This version is setup here to ignore any exports marked with @internal, but you could change that check for anything you like.

const path = require('path')
const { Project, Symbol } = require('ts-morph')

const jsDocText = /\/\*\*([\s\S]+)\*\//
const jsDocTag = /^[ \t]*?@.*$/gm

// Which files should be imported and inspected
const entrypoints = ['lib.js']

function api() {
  const project = new Project({ tsConfigFilePath: 'tsconfig.json' })

  // This is where our processed AST is going to get stored and return
  const output = {}

  // Loop through each of our entry-points
  for (const entry of project.getSourceFiles(entrypoints)) {
    // Create a friendly name for the entry-point (by removing the absolute path)
    const entryName = path.relative(process.cwd(), entry.getFilePath())

    // Start compiling the entry-point
    output[entryName] = {}

    // Loop through each symbol that is exported from the file
    // NOTE: these may be export symbols in the entry-point
    // and not the actual code definitions with useful information on them
    for (let symbol of entry.getExportSymbols()) {
      // Skip any symbol marked with @internal
      if (symbol.getJsDocTags().some((t) => t.getName() === 'internal'))
        continue

      // If it is an alias, make sure to get what is aliased instead
      if (symbol.isAlias()) symbol = symbol.getAliasedSymbolOrThrow()

      // Put the export into the entry-point
      // You could do more processing here to capture more information if you like
      output[entryName][symbol.getEscapedName()] = {
        entryPoint: entryName,
        name: symbol.getEscapedName(),
        content: joinDocComments(symbol),
        tags: symbol.getJsDocTags(),
      }
    }
  }

  return output
}

/**
  Compose all doc comments on a Symbol together into one markdown string.
  It gets the text out of a JSDoc comment,
  then strips out the annotations and joins them all as markdown paragraphs
  
  @param {Symbol} symbol
*/
function joinDocComments(symbol) {
  const sections = []

  // Each symbol might have one or more declarations,
  // each of which might have zero or more JSDoc comment regions
  for (const declaration of symbol.getDeclarations()) {
    for (const range of declaration.getLeadingCommentRanges()) {
      const match = jsDocText.exec(range.getText())
      if (!match) continue
      sections.push(match[1].replaceAll(jsDocTag, ''))
    }
  }

  // Join all sections together with two newlines to make sure they are
  // seperate paragraphs in markdown
  return sections.join('\n\n')
}

module.exports = api()

You should now have a directory structure something like this:

.
├── _data
│   └── api.js
├── _includes
│   └── html.njk
├── docs.njk
├── eleventy.config.js
├── guide.md
├── index.md
├── lib.js
├── package-lock.json
├── package.json
└── tsconfig.json

With all that setup, we can build and serve our site with npx eleventy --serve and open it up in the browser 🥳. It should look like the pictures below:

The first page is a "guide", which shows how to embed specific bits of the API using the apiDoc shortcode.

The guide page with some crafted notes and JSDoc snippets embedded in-between.
The guide page with some crafted notes and JSDoc snippets embedded in-between.

The second page is a catch-all "docs" page that dumps the entire API grouped by entry-point.

The docs page listing out each entry-point and each named export in them.
The docs page listing out each entry-point and each named export in them.

To recap,

  1. There is an API we want to document in lib.js
  2. That file has JSDoc comments in, documenting the code in-place
  3. From _data/api.js, we parse the AST of the code and the JSDoc comments to make it available in Eleventy
  4. That data is available in templates globally as api
  5. There is an apiDoc shortcode to quickly render a named export from a specific entry-point
  6. It all gets built into a nice website with syntax highlighting from prism

Next steps

My use case was just getting those JSDoc comments out of the code and into the website, but in exploring that there are more things I think you could do.

better embedding — I left the apiDoc shortcode quite brief on purpose, you might want a url-slug id in there or to use a different heading tag.

Actually use TypeScript — The example lib.js is just JavaScript, you can of course use it with TypeScript too. I was just trying to keep things simple here.

More TypeScript integration — There is a load more information in the TypeScript AST that isn't being used, I tried getting it to generate code signatures before but didn't get very far. There are also some cool things that could be done with the "tags" in JSDoc comments, e.g. @param, maybe they could be processed more and put into HTML tables or something.

A library — with some more iteration, and some interest, there could be a nice library/eleventy-plugin here to make this a lot easier in the future, it's quite a lot of code in this post 🙄.

Work out the "hack" in eleventy.config.js — I'm not sure how properly get the syntax highlighting to work without relying on the mutation of my md instance in there.

Configure the Watch Target — there is only one file in the API here but with multiple you'll want to pass a glob pattern to eleventyConfig.addWatchTarget to make it reload when any of your source code changes.


Props to Zach for prompting me to do this.

Hit me up on Mastodon if you liked this, have feedback or just want to know more!