3.0.1 • Published 8 years ago

essay v3.0.1

Weekly downloads
4
License
MIT
Repository
github
Last release
8 years ago

essay

NPM version Build status Code coverage

Generate your JavaScript library out of an essay!

synopsis

For example, you could write your library in a fenced code block in your README.md file like this:

// examples/add.js
export default (a, b) => a + b

And also write a test for it:

// examples/add.test.js
import add from './add'

// describe block called automatically for us!
it('should add two numbers', () => {
  assert(add(1, 2) === 3)
})

getting started

  1. Make sure you already have your README.md file in place and already initialized your npm package (e.g. using npm init).

  2. Install essay as your dev dependency.

    npm install --save-dev essay
  3. Ignore these folders in .npmignore:

    src
    lib
    lib-cov
  4. Add "files" array to your package.json:

    "files": [
      "lib",
      "src"
    ]
  5. Add the "scripts" to your package.json:

    "scripts": {
      "prepublish": "essay build",
      "test": "essay test"
    },
  6. Set your main to the file you want to use, prefixed with lib/:

    "main": "lib/index.js",

building

When you run:

npm run prepublish # -> essay build

These code blocks will be extracted into its own file:

src
└── examples
    ├── add.js
    └── add.test.js
lib
└── examples
    ├── add.js
    └── add.test.js

The src folder contains the code as written in README.md, and the lib folder contains the transpiled code.

testing

When you run:

npm test # -> essay test

All the files ending with .test.js will be run using Mocha framework. power-assert is included by default (but you can use any assertion library you want).

  examples/add.test.js
    ✓ should add two numbers

Additionally, test coverage report for your README.md file will be generated.

development

You need to use npm install --force, because I use essay to write essay, but npm doesn’t want to install a package as a dependency of itself (even though it is an older version).

commands

essay build

// cli/buildCommand.js
import obtainCodeBlocks from '../obtainCodeBlocks'
import dumpSourceCodeBlocks from '../dumpSourceCodeBlocks'
import transpileCodeBlocks from '../transpileCodeBlocks'
import getBabelConfig from '../getBabelConfig'

export const command = 'build'
export const description = 'Builds the README.md file into lib folder.'
export const builder = (yargs) => yargs
export const handler = async (argv) => {
  const babelConfig = getBabelConfig()
  const targetDirectory = 'lib'
  const codeBlocks = await obtainCodeBlocks()
  await dumpSourceCodeBlocks(codeBlocks)
  await transpileCodeBlocks({ targetDirectory, babelConfig })(codeBlocks)
}

essay test

// cli/testCommand.js
import obtainCodeBlocks from '../obtainCodeBlocks'
import dumpSourceCodeBlocks from '../dumpSourceCodeBlocks'
import transpileCodeBlocks from '../transpileCodeBlocks'
import getTestingBabelConfig from '../getTestingBabelConfig'
import runUnitTests from '../runUnitTests'

export const command = 'test'
export const description = 'Runs the test.'
export const builder = (yargs) => yargs
export const handler = async (argv) => {
  const babelConfig = getTestingBabelConfig()
  const targetDirectory = 'lib-cov'
  const codeBlocks = await obtainCodeBlocks()
  await dumpSourceCodeBlocks(codeBlocks)
  await transpileCodeBlocks({ targetDirectory, babelConfig })(codeBlocks)
  await runUnitTests(codeBlocks)
}

obtaining code blocks

This IO function reads your README.md and extracts the fenced code blocks.

// obtainCodeBlocks.js
import extractCodeBlocks from './extractCodeBlocks'
import fs from 'fs'

export async function obtainCodeBlocks () {
  const readme = fs.readFileSync('README.md', 'utf8')
  const codeBlocks = extractCodeBlocks(readme)
  return codeBlocks
}

export default obtainCodeBlocks

code block extraction

This function takes a string (representing your README.md) and extracts the fenced code block. Returns an object of code block entries, which contains the contents and line number in README.md.

See the test (below) for more details:

// extractCodeBlocks.js
export function extractCodeBlocks (data) {
  const codeBlocks = { }
  const regexp = /(`[`]`js\s+\/\/\s*(\S+).*\n)([\s\S]+?)`[`]`/g
  data.replace(regexp, (all, before, filename, contents, index) => {
    if (codeBlocks[filename]) throw new Error(filename + ' already exists!')

    // XXX: Not the most efficient way to find the line number.
    const line = data.substr(0, index + before.length).split('\n').length

    codeBlocks[filename] = { contents, line }
  })
  return codeBlocks
}

export default extractCodeBlocks

Here’s the test for this function:

// extractCodeBlocks.test.js
import extractCodeBlocks from './extractCodeBlocks'

const END = '`' + '`' + '`'
const BEGIN = END + 'js'

const example = [
  'Hello world!',
  '============',
  '',
  BEGIN,
  '// file1.js',
  'console.log("hello,")',
  END,
  '',
  '- It should work in lists too!',
  '',
  '  ' + BEGIN,
  '  // file2.js',
  '  console.log("world!")',
  '  ' + END,
  '',
  'That’s it!'
].join('\n')

const blocks = extractCodeBlocks(example)

it('should extract code blocks into object', () => {
  assert.deepEqual(Object.keys(blocks).sort(), [ 'file1.js', 'file2.js' ])
})

it('should contain the code block’s contents', () => {
  assert(blocks['file1.js'].contents.trim() === 'console.log("hello,")')
  assert(blocks['file2.js'].contents.trim() === 'console.log("world!")')
})

it('should contain line numbers', () => {
  assert(blocks['file1.js'].line === 6)
  assert(blocks['file2.js'].line === 13)
})

dumping code blocks to source files

// dumpSourceCodeBlocks.js
import forEachCodeBlock from './forEachCodeBlock'
import saveToFile from './saveToFile'
import path from 'path'

export const dumpSourceCodeBlocks = forEachCodeBlock(async ({ contents }, filename) => {
  const targetFilePath = path.join('src', filename)
  await saveToFile(targetFilePath, contents)
})

export default dumpSourceCodeBlocks

transpilation using Babel

// transpileCodeBlocks.js
import forEachCodeBlock from './forEachCodeBlock'
import transpileCodeBlock from './transpileCodeBlock'

export function transpileCodeBlocks (options) {
  return forEachCodeBlock(transpileCodeBlock(options))
}

export default transpileCodeBlocks

transpiling an individual code block

To speed up transpilation, we’ll skip the transpilation process if the source file has not been modified since the corresponding transpiled file has been generated (similar to make).

// transpileCodeBlock.js
import path from 'path'
import fs from 'fs'
import { transformFileSync } from 'babel-core'

import saveToFile from './saveToFile'

export function transpileCodeBlock ({ babelConfig, targetDirectory } = { }) {
  return async function (codeBlock, filename) {
    const sourceFilePath = path.join('src', filename)
    const targetFilePath = path.join(targetDirectory, filename)
    if (await isAlreadyUpToDate(sourceFilePath, targetFilePath)) return
    const { code } = transformFileSync(sourceFilePath, babelConfig)
    await saveToFile(targetFilePath, code)
  }
}

async function isAlreadyUpToDate (sourceFilePath, targetFilePath) {
  if (!fs.existsSync(targetFilePath)) return false
  const sourceStats = fs.statSync(sourceFilePath)
  const targetStats = fs.statSync(targetFilePath)
  return targetStats.mtime > sourceStats.mtime
}

export default transpileCodeBlock

babel configuration

// getBabelConfig.js
export function getBabelConfig () {
  return {
    presets: [
      require('babel-preset-es2015'),
      require('babel-preset-stage-2'),
    ],
    plugins: [
      require('babel-plugin-transform-runtime')
    ]
  }
}

export default getBabelConfig

additional options for testing

// getTestingBabelConfig.js
import getBabelConfig from './getBabelConfig'

export function getTestingBabelConfig () {
  const babelConfig = getBabelConfig()
  return {
    ...babelConfig,
    plugins: [
      require('babel-plugin-__coverage__'),
      require('babel-plugin-espower'),
      ...babelConfig.plugins
    ]
  }
}

export default getTestingBabelConfig

running unit tests

It’s quite hackish right now, but it works.

// runUnitTests.js
import fs from 'fs'
import saveToFile from './saveToFile'
import mapSourceCoverage from './mapSourceCoverage'

export async function runUnitTests (codeBlocks) {
  // Generate an entry file for mocha to use.
  const testEntryFilename = './lib-cov/_test-entry.js'
  const entry = generateEntryFile(codeBlocks)
  await saveToFile(testEntryFilename, entry)

  // Initialize mocha with the entry file.
  const Mocha = require('mocha')
  const mocha = new Mocha({ ui: 'bdd' })
  mocha.addFile(testEntryFilename)

  // Now go!!
  prepareTestEnvironment()
  await runMocha(mocha)
  await saveCoverageData(codeBlocks)
}

function runMocha (mocha) {
  return new Promise((resolve, reject) => {
    mocha.run(function (failures) {
      if (failures) {
        reject(new Error('There are ' + failures + ' test failure(s).'))
      } else {
        resolve()
      }
    })
  })
}

function generateEntryFile (codeBlocks) {
  const entry = [ '"use strict";' ]
  for (const filename of Object.keys(codeBlocks)) {
    if (filename.match(/\.test\.js$/)) {
      entry.push('describe(' + JSON.stringify(filename) + ', function () {')
      entry.push('  require(' + JSON.stringify('./' + filename) + ')')
      entry.push('})')
    }
  }
  return entry.join('\n')
}

function prepareTestEnvironment () {
  global.assert = require('power-assert')
}

async function saveCoverageData (codeBlocks) {
  const coverage = global['__coverage__']
  if (!coverage) return
  const istanbul = require('istanbul')
  const reporter = new istanbul.Reporter()
  const collector = new istanbul.Collector()
  const synchronously = true
  collector.add(mapSourceCoverage(coverage, {
    codeBlocks,
    sourceFilePath: fs.realpathSync('README.md'),
    targetDirectory: fs.realpathSync('src')
  }))
  reporter.add('lcov')
  reporter.add('text')
  reporter.write(collector, synchronously, () => { })
}

export default runUnitTests

the coverage magic

This module rewrites the coverage data so that you can view the coverage report for README.md. It’s quite complex and thus deserves its own section.

// mapSourceCoverage.js
import path from 'path'
import { forOwn } from 'lodash'

export function mapSourceCoverage (coverage, {
  codeBlocks,
  sourceFilePath,
  targetDirectory
}) {
  const result = { }
  const builder = createReadmeDataBuilder(sourceFilePath)
  for (const key of Object.keys(coverage)) {
    const entry = coverage[key]
    const relative = path.relative(targetDirectory, entry.path)
    if (codeBlocks[relative]) {
      builder.add(entry, codeBlocks[relative])
    } else {
      result[key] = entry
    }
  }
  if (!builder.isEmpty()) result[sourceFilePath] = builder.getOutput()
  return result
}

function createReadmeDataBuilder (path) {
  let nextId = 1
  let output = {
    path,
    s: { }, b: { }, f: { },
    statementMap: { }, branchMap: { }, fnMap: { }
  }
  let empty = true
  return {
    add (entry, codeBlock) {
      const id = nextId++
      const prefix = (key) => `${id}.${key}`
      const mapLine = (line) => codeBlock.line - 1 + line
      const map = mapLocation(mapLine)
      empty = false
      forOwn(entry.s, (count, key) => {
        output.s[prefix(key)] = count
      })
      forOwn(entry.statementMap, (loc, key) => {
        output.statementMap[prefix(key)] = map(loc)
      })
      forOwn(entry.b, (count, key) => {
        output.b[prefix(key)] = count
      })
      forOwn(entry.branchMap, (branch, key) => {
        output.branchMap[prefix(key)] = {
          ...branch,
          line: mapLine(branch.line),
          locations: branch.locations.map(map)
        }
      })
      forOwn(entry.f, (count, key) => {
        output.f[prefix(key)] = count
      })
      forOwn(entry.fnMap, (fn, key) => {
        output.fnMap[prefix(key)] = {
          ...fn,
          line: mapLine(fn.line),
          loc: map(fn.loc)
        }
      })
    },
    isEmpty: () => empty,
    getOutput: () => output
  }
}

function mapLocation (mapLine) {
  return ({ start = { }, end = { } }) => ({
    start: { line: mapLine(start.line), column: start.column },
    end: { line: mapLine(end.line), column: end.column }
  })
}

export default mapSourceCoverage

And its tests is quite… ugh!

// mapSourceCoverage.test.js
import mapSourceCoverage from './mapSourceCoverage'
import path from 'path'

const loc = (startLine, startColumn) => (endLine, endColumn) => ({
  start: { line: startLine, column: startColumn },
  end: { line: endLine, column: endColumn }
})
const coverage = {
  '/home/user/essay/src/hello.js': {
    path: '/home/user/essay/src/hello.js',
    s: { 1: 1 },
    b: { 1: [ 1, 2, 3 ] },
    f: { 1: 99, 2: 30 },
    statementMap: {
      1: loc(2, 15)(2, 30)
    },
    branchMap: {
      1: { line: 4, type: 'switch', locations: [
        loc(5, 10)(5, 20),
        loc(7, 10)(7, 25),
        loc(9, 10)(9, 30)
      ] }
    },
    fnMap: {
      1: { name: 'x', line: 10, loc: loc(10, 0)(10, 20) },
      2: { name: 'y', line: 20, loc: loc(20, 0)(20, 15) },
    },
  },
  '/home/user/essay/src/world.js': {
    path: '/home/user/essay/src/world.js',
    s: { 1: 1 },
    b: { },
    f: { },
    statementMap: { 1: loc(1, 0)(1, 30) },
    branchMap: { },
    fnMap: { }
  },
  '/home/user/essay/unrelated.js': {
    path: '/home/user/essay/unrelated.js',
    s: { 1: 1 },
    b: { },
    f: { },
    statementMap: { 1: loc(1, 0)(1, 30) },
    branchMap: { },
    fnMap: { }
  }
}
const codeBlocks = {
  'hello.js': { line: 72 },
  'world.js': { line: 99 }
}
const getMappedCoverage = () => mapSourceCoverage(coverage, {
  codeBlocks,
  sourceFilePath: '/home/user/essay/README.md',
  targetDirectory: '/home/user/essay/src'
})

it('should combine mappings from code blocks', () => {
  const mapped = getMappedCoverage()
  assert(Object.keys(mapped).length === 2)
})

const testReadmeCoverage = (f) => () => (
  f(getMappedCoverage()['/home/user/essay/README.md'])
)

it('should have statements', testReadmeCoverage(entry => {
  const keys = Object.keys(entry.s)
  assert(keys.length === 2)
  assert.deepEqual(keys, [ '1.1', '2.1' ])
}))
it('should have statementMap', testReadmeCoverage(({ statementMap }) => {
  assert(statementMap['1.1'].start.line === 73)
  assert(statementMap['1.1'].end.line === 73)
  assert(statementMap['2.1'].start.line === 99)
  assert(statementMap['2.1'].end.line === 99)
}))
it('should have branches', testReadmeCoverage(({ b }) => {
  assert(Array.isArray(b['1.1']))
}))
it('should have branchMap', testReadmeCoverage(({ branchMap }) => {
  assert(branchMap['1.1'].locations[2].start.line === 80)
  assert(branchMap['1.1'].line === 75)
}))
it('should have functions', testReadmeCoverage(({ f }) => {
  assert(f['1.1'] === 99)
  assert(f['1.2'] === 30)
}))
it('should have function map', testReadmeCoverage(({ fnMap }) => {
  assert(fnMap['1.1'].loc.start.line === 81)
  assert(fnMap['1.2'].line === 91)
}))

acceptance test

// acceptance.test.js
import mkdirp from 'mkdirp'
import fs from 'fs'
import * as buildCommand from './cli/buildCommand'
import * as testCommand from './cli/testCommand'

it('works', async () => {
  const example = fs.readFileSync('example.md', 'utf8')
  await runInTemporaryDir(async () => {
    fs.writeFileSync('README.md', example)
    await buildCommand.handler({ })
    assert(fs.existsSync('src/add.js'))
    assert(fs.existsSync('lib/add.js'))
    await testCommand.handler({ })
    assert(fs.existsSync('coverage/lcov.info'))
  })
})

async function runInTemporaryDir (f) {
  const cwd = process.cwd()
  const testDirectory = '/tmp/essay-acceptance-test'
  mkdirp.sync(testDirectory)
  try {
    process.chdir(testDirectory)
    await f()
  } finally {
    process.chdir(cwd)
  }
}

miscellaneous

command line handler

// cli/index.js
import * as buildCommand from './buildCommand'
import * as testCommand from './testCommand'

export function main () {
  // XXX: Work around yargs’ lack of default command support.
  const commands = [ buildCommand, testCommand ]
  const yargs = commands.reduce(appendCommandToYargs, require('yargs')).help()
  const registry = commands.reduce(registerCommandToRegistry, { })
  const argv = yargs.argv
  const command = argv._.shift() || 'build'
  const commandObject = registry[command]
  if (commandObject) {
    const subcommand = commandObject.builder(yargs.reset())
    Promise.resolve(commandObject.handler(argv)).catch(e => {
      setTimeout(() => { throw e })
    })
  } else {
    yargs.showHelp()
  }
}

function appendCommandToYargs (yargs, command) {
  return yargs.command(command.command, command.description)
}

function registerCommandToRegistry (registry, command) {
  return Object.assign(registry, {
    [command.command]: command
  })
}

saving file

This function wraps around the normal file system API, but provides this benefits:

  • It will first read the file, and if the content is identical, it will not re-write the file.

  • It displays log message on console.

// saveToFile.js
import fs from 'fs'
import path from 'path'
import mkdirp from 'mkdirp'

export async function saveToFile (filePath, contents) {
  mkdirp.sync(path.dirname(filePath))
  const exists = fs.existsSync(filePath)
  if (exists) {
    const existingData = fs.readFileSync(filePath, 'utf8')
    if (existingData === contents) return
  }
  console.log('%s %s…', exists ? 'Updating' : 'Writing', filePath)
  fs.writeFileSync(filePath, contents)
}

export default saveToFile

run a function for each code block

// forEachCodeBlock.js
export function forEachCodeBlock (fn) {
  return async function (codeBlocks) {
    const filenames = Object.keys(codeBlocks)
    for (const filename of filenames) {
      await fn(codeBlocks[filename], filename, codeBlocks)
    }
  }
}

export default forEachCodeBlock