loading...

Leveraging Node Modules to Provide Offline Documentation

ramsay profile image Ramsay ・5 min read

Are you a young, hip, developer-on-the-go? Neither am I, but I do tend to do some application development in situations where I don’t have access to the internet. Also I’m not hip. And youth is fleeting.

Anyway.

There are very good solutions for obtaining offline-documentation. DevDocs comes to mind, because it offers great offline documentation storage for lots of well-used software, and it has tons of features. In fact, you should probably just use that. However, you can’t just add offline documentation for any package you want. There is some gatekeeping that occurs.

If an existing solution like DevDocs doesn’t suit your needs, you can leverage the documentation that ships with the packages you’ve installed in your application. That’s right, I’m talking about README.md files.

Over the weekend ™️, I had the idea of building a little CLI tool that could create an express server that would look for the node_modules directory and serve up the contents of each packages’ README.md file. The tool would also provide a web interface for you to search node_modules for packages. It could also uses IndexedDB to store favorites offline.

So I did it. The result is Module Docs and you can install it as a Node package.

module docs ui screenshot

You can install it globally, or per project. After installing, start the cli by running:

$ module-docs start

You can create an npm script that will automatically start module-docs as part of the dev process. Here’s how I use it:

{
  "scripts:" {
    "start": "npm run start:docs && webpack-dev-server",
    "start:docs": "module-docs start"
  }
}

You can configure module-docs for each project you want to use it on by creating a module-docs.config.js file in the root of that projects’ directory. Currently, you can provide an array of package names to include as favorites, like so:

// module-docs.config.js
module.exports = {
   favorites: ["react", "react-apollo", "react-apollo-hooks"]
}

If you just want to use it, you can stop reading here and go live your best life. If you want to read on about how it was built, bless up and keep reading.

Creating the CLI

In order to create the cli, I’ll use commander.js, which is a very popular CLI building tool.

const program = require("commander")
const makeServer = require("./server/serve")
const path = require("path")

// gets the config file from the working directory of the application 
const getConfig = () => {
  const configPath = path.join(process.cwd(), "./module-docs.config.js")
  const config = require(configPath)
  return config ? config || null
}

// using commander, execute the start command which spins up the express server
program.command("start").action(() => {
  const modulePath = path.join(process.cwd(), "./node_modules")
  const config = getConfig()
  makeServer(modulePath, config)
})

program.parse(process.argv)

This is the jumping off point for the entire module_docs package. Its what allows you to run module-docs start to start up the express server. Let’s take a look at the server.

Building The Server

The server is a pretty basic Node server build using Express. It uses webpack-dev-middleware to create a dev server that will serve up a React application for the web UI.

const express = require("express")
const webpack = require("webpack")
const config = require("../webpack.config")
const devMiddleware = require("webpack-dev-middleware")
const compiler = webpack(config)
const bodyParser = require("body-parser")

// controller to handle API requests
const FileController = require("./controllers")

// Probably should make this configurable 
const PORT = 4444


module.exports = (modulePath, config) => {
  const app = express()

  app.use(bodyParser.json())

  // start webpack dev server
  app.use(
    devMiddleware(compiler, {
      open: true,
      stats: "errors-only"
    })
  )

  // handles getting package names from node_modules
  app.post("/modules", FileController.getFiles(modulePath, config))

  // handles getting the package info and README from a package
  app.post("/module/:name", FileController.getPackage(modulePath))

  app.get("*", function response(req, res) {
    res.sendFile("./client/template.html", { root: __dirname })
  })

  app.listen(PORT, () => {
    console.log(`Module Docs is running at http://localhost:${PORT}`)
  })
}

As you can see, there are two API endpoints. The first endpoint handles getting the directory names from node_modules. The second endpoint gets the README contents and parses package.json for information about the package. Currently, the UI just displays the package version and a link to the package’s homepage, if there is one.

To handle the POST request, I’ve created a FileController. This is where all the heavy lifting is.

The FileController

This file could definitely use some refactoring. That being said, I’ll break this file down into chunks. First, the utility functions and imports:

const fs = require("fs")
const pipe = require("lodash/fp/pipe")
const some = require("lodash/some")
const promisify = require("util").promisify
const readdir = promisify(fs.readdir)
const readFile = promisify(fs.readFile)

// directories to exclude from the search
const blacklist = [".bin", ".cache", ".yarn-integrity"]

const filterThroughBlacklist = files =>
  files.filter(f => !blacklist.includes(f))

// check to see if the list of files includes the filename
const checkFilesForFile = files => fileName =>
  some(files, f => f.name === fileName)

// Get all the files in the package that are directories. This is used
// for mono-repos are scoped packages that don't contain README files directly. 
// I could probably refactor this and the blackListFilter into one util function
const getDirectories = files =>
  files.filter(f => f.isDirectory() && f.name !== "node_modules")

// checks a package directory to see if it contains a README or a package.json file
const checkPackage = files => {
  const checkFilesFor = checkFilesForFile(files)
  return {
    hasReadme: checkFilesFor("README.md"),
    hasPackageInfo: checkFilesFor("package.json")
  }
}

// gets the content of the README and the package.json file, if they exist
const getDirectoryContent = async directory => {
  const files = await readdir(directory, { withFileTypes: true })
  const { hasReadme, hasPackageInfo } = checkPackage(files)
  const readmeContent =
    hasReadme && (await readFile(`${directory}/README.md`, "utf8"))

  const packageInfo =
    hasPackageInfo && (await readFile(`${directory}/package.json`, "utf8"))

  return {
    files,
    readmeContent,
    packageInfo
  }
}

// If a package has sub-directories, check each directory for a README and package.json
// If they exists, get contents of each and return
const getPackagesFromChildren = parentDir => children => {
  const readmes = children.map(async child => {
    const childDir = `${parentDir}/${child.name}`
    const { readmeContent, packageInfo } = await getDirectoryContent(childDir)
    return readmeContent || packageInfo
      ? {
          name: child.name,
          path: `${childDir}/README.md`,
          content: readmeContent,
          info: packageInfo
        }
      : {}
  })

  return Promise.all(readmes)
}

It’s important to note that I’ve created a blacklist of files to exclude from searching, because they are in node_modules but aren’t useful for our purposes. I’m sure this list is not all encompassing.

Also, we should provide a list of sub-directories (children) that contain README and package.json files, in case the package in question is a mono-repo, or a scoped package, like babel. This is what getPackagesFromChildren does.

The above util functions will be used in the FileController handlers. Let's take a look at them.

// gets directory names from a path, excluding blacklisted names. Returns an array of strings.
exports.getFiles = (path, config) => async (req, res) => {
  const files = await readdir(path)
  const filteredFiles = filterThroughBlacklist(files)
  res.send({ files: filteredFiles, config })
}

// Gets README content for package and all first-level children.
exports.getPackage = path => async (req, res) => {
  const name = req.params.name
  const dir = `${path}/${name}`

  try {
    const { files, readmeContent, packageInfo } = await getDirectoryContent(dir)
    const children = await pipe(
      getDirectories,
      getPackagesFromChildren(dir)
    )(files)

    const pkg = {
      path: dir,
      content: readmeContent,
      info: packageInfo,
      children: children
    }

    res.send({ pkg })
  } catch (err) {
    console.log("Unable to scan directory: " + err)
    res.send({ pkg: "No Readme Found" })
  }
}

That’s pretty much it for the server-side of things.

As for the front-end, its built using React (as of writing 16.8-alpha, so I can use those sweet sweet hooks). You're best bet is to play around with the CodeSandbox below.

Wrap Up

Not bad for a weekend. I’m going to keep working on some other features that I’d personally like to use, like being able to add custom notes to each package, and being able to link and fetch documentation and saving that offline as well. Let me know what you think!

Posted on Jan 17 '19 by:

ramsay profile

Ramsay

@ramsay

At work, I'm a senior web developer. At home, I'm a senior dad joke developer.

Discussion

markdown guide