Generate your JavaScript library out of an essay!
-
Write code in your README.md file in fenced code blocks, with a comment indicating the file’s name.
-
Write code using ES2016 syntax.
essay
will use Babel to transpile them to ES5. -
Test your code using Mocha and power-assert.
-
Measures your code coverage.
essay
generates code coverage report for your README.md file using Istanbul and babel-plugin-__coverage__. -
Examples of JavaScript libraries/articles written using
essay
:Project Description circumstance BDD for your pure state-updating functions (e.g. Redux reducers) positioning-strategy A library that implements an algorithm to calculate where to position an element relative to another element code-to-essay Turns your code into an essay-formatted Markdown file. timetable-calculator An algorithm for computing the layout for a timetable, handling the case where items are overlapped. impure A simple wrapper object for non-deterministic code (like IO monads in Haskell) - Using essay to write a library/article? Feel free to add your link here: please submit a PR!
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)
})
The easiest way to get started is to use initialize-essay.
Manual setup steps
-
Make sure you already have your README.md file in place and already initialized your npm package (e.g. using
npm init
). -
Install
essay
as your dev dependency.npm install --save-dev essay
-
Ignore these folders in
.gitignore
:src lib lib-cov
-
Add
"files"
array to yourpackage.json
:"files": [ "lib", "src" ]
-
Add the
"scripts"
to yourpackage.json
:"scripts": { "prepublish": "essay build", "test": "essay test" },
-
Set your main to the file you want to use, prefixed with
lib/
:"main": "lib/index.js",
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.
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.
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).
// 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)
}
// 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)
}
// cli/lintCommand.js
import obtainCodeBlocks from '../obtainCodeBlocks'
import runLinter from '../runLinter'
import moduleExists from 'module-exists'
export const command = 'lint'
export const description = 'Runs the linter.'
export const builder = (yargs) => yargs
export const handler = async (argv) => {
if (allowToUseESLint(moduleExists('eslint'))) {
const codeBlocks = await obtainCodeBlocks()
await runLinter(codeBlocks, argv)
}
}
export const allowToUseESLint = (hasESLintModule) => {
if (hasESLintModule) return true
console.log('Please install eslint')
process.exitCode = 1
}
essay extracts the fenced code blocks from your README.md file:
// 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
It extracts a fenced code block that looks like this:
```js
// filename.js
export default 42
```
Once extracted, each code block will have its associated file name, contents, and the line number.
// 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
Test:
// 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)
})
Extracted code blocks are first dumped into src
directory.
// 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
We transpile each code block using Babel.
// transpileCodeBlocks.js
import forEachCodeBlock from './forEachCodeBlock'
import transpileCodeBlock from './transpileCodeBlock'
export function transpileCodeBlocks (options) {
return forEachCodeBlock(transpileCodeBlock(options))
}
export default transpileCodeBlocks
To speed up transpilation, we’ll skip running Babel 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
// getBabelConfig.js
export function getBabelConfig () {
return {
presets: [
require('babel-preset-latest'),
require('babel-preset-stage-2')
],
plugins: [
require('babel-plugin-transform-runtime')
]
}
}
export default getBabelConfig
// getTestingBabelConfig.js
import getBabelConfig from './getBabelConfig'
import babelPluginIstanbul from 'babel-plugin-istanbul'
import babelPresetPowerAssert from 'babel-preset-power-assert'
export function getTestingBabelConfig () {
const babelConfig = getBabelConfig()
return {
...babelConfig,
presets: [
babelPresetPowerAssert,
...babelConfig.presets
],
plugins: [
babelPluginIstanbul,
...babelConfig.plugins
]
}
}
export default getTestingBabelConfig
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
// runLinter.js
import fs from 'fs'
import saveToFile from './saveToFile'
import padRight from 'lodash/padEnd'
import flatten from 'lodash/flatten'
import isEmpty from 'lodash/isEmpty'
import compact from 'lodash/compact'
import { CLIEngine } from 'eslint'
import Table from 'cli-table'
import moduleExists from 'module-exists'
export const runESLint = (contents, fix, eslintExtends) => {
const cli = new CLIEngine({
fix,
globals: ['describe', 'it', 'should'],
...eslintExtends
})
const report = cli.executeOnText(contents)
return report.results
}
export const formatLinterErrorsColumnMode = (errors, resetStyle = {}) => {
if (isEmpty(errors)) return ''
const table = new Table({ head: ['Where', 'Path', 'Rule', 'Message'], ...resetStyle })
errors.map((error) => table.push([
error.line + ':' + error.column,
error.filename,
error.ruleId,
error.message
]))
return table.toString()
}
const formatLinterSolution = (line, filename, output) => ({
line,
filename,
fixedCode: output
})
const formatLinterError = (line, filename, error) => {
error.line += line - 1
error.filename = filename
return error
}
export const isFix = (options) => !!options._ && options._[0] === 'fix'
export const generateCodeBlock = (code, filename) => {
const END = '`' + '`' + '`'
const BEGIN = END + 'js'
return [
BEGIN,
'// ' + filename,
code + END
].join('\n')
}
export const fixLinterErrors = async (errors, codeBlocks, targetPath = 'README.md') => {
let readme = fs.readFileSync(targetPath, 'utf8')
errors.map(({ filename, fixedCode }) => {
const code = codeBlocks[filename].contents
if (fixedCode) {
readme = readme.split(generateCodeBlock(code, filename)).join(generateCodeBlock(fixedCode, filename))
}
})
await saveToFile(targetPath, readme)
}
const mergeLinterResults = (prev, cur) => ({
solutions: compact([...prev.solutions, ...cur.solutions]),
remainingErrors: compact([...prev.remainingErrors, ...cur.remainingErrors])
})
const defaultLinterResults = { solutions: [], remainingErrors: [] }
export const mapLinterErrorsToLine = (results, line, filename) => (
results.map(({ messages, output }) => ({
solutions: [formatLinterSolution(line, filename, output)],
remainingErrors: messages.map((error) => formatLinterError(line, filename, error))
})).reduce(mergeLinterResults, defaultLinterResults)
)
export const getESLintExtends = (hasStandardPlugin) => (
hasStandardPlugin
? { baseConfig: { extends: ['standard'] } }
: {}
)
export async function runLinter (codeBlocks, options) {
let linterResults = defaultLinterResults
const fix = isFix(options)
Object.keys(codeBlocks).map(filename => {
const { contents, line } = codeBlocks[filename]
const eslintExtends = getESLintExtends(moduleExists('eslint-plugin-standard'))
const results = runESLint(contents, fix, eslintExtends)
linterResults = mergeLinterResults(linterResults, mapLinterErrorsToLine(results, line, filename))
})
if (fix) await fixLinterErrors(linterResults.solutions, codeBlocks)
console.error(formatLinterErrorsColumnMode(linterResults.remainingErrors))
}
export default runLinter
And its tests
// runLinter.test.js
import {
runESLint,
mapLinterErrorsToLine,
formatLinterErrorsColumnMode,
isFix,
generateCodeBlock,
getESLintExtends
} from './runLinter'
it('should map linter errors back to line in README.md', () => {
const linterResults = [
{ messages: [{ message: 'message-1', line: 2 }], output: 'fixed-code' }
]
const { solutions, remainingErrors } = mapLinterErrorsToLine(linterResults, 5, 'example.js')
assert.deepEqual(remainingErrors, [{
line: 6,
filename: 'example.js',
message: 'message-1'
}])
assert.deepEqual(solutions, [{
line: 5,
filename: 'example.js',
fixedCode: 'fixed-code'
}])
})
it('should format linter errors on a column mode', () => {
const errors = [{
line: 5,
column: 10,
message: 'message',
filename: 'example.js',
ruleId: 'ruleId-1'
}]
const resetStyle = { style: { head: [], border: [] } }
const table = formatLinterErrorsColumnMode(errors, resetStyle)
assert(table === [
'┌───────┬────────────┬──────────┬─────────┐',
'│ Where │ Path │ Rule │ Message │',
'├───────┼────────────┼──────────┼─────────┤',
'│ 5:10 │ example.js │ ruleId-1 │ message │',
'└───────┴────────────┴──────────┴─────────┘'
].join('\n'))
const tableNoErrors = formatLinterErrorsColumnMode([], resetStyle)
assert(tableNoErrors === '')
})
it('should trigger fix mode when \'fix\' option is given', () => {
assert(isFix({ _: ['fix'] }) === true)
assert(isFix({}) === false)
})
it('should insert javascript code block', () => {
assert(generateCodeBlock('const x = 5\n', 'example.js') === [
'`' + '`' + '`js',
'// example.js',
'const x = 5',
'`' + '`' + '`'
].join('\n'))
})
it('should get correct base config', () => {
assert(getESLintExtends(false), {})
assert(getESLintExtends(true).baseConfig.extends, 'standard')
})
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.js
import mkdirp from 'mkdirp'
import fs from 'fs'
import * as buildCommand from './cli/buildCommand'
import * as testCommand from './cli/testCommand'
import * as lintCommand from './cli/lintCommand'
const assertProcessExitCode = (expectedCode) => (
process.on('exit', (code) => {
assert(code === expectedCode)
delete process.exitCode
})
)
it('works', async () => {
const example = fs.readFileSync('example.md', 'utf8')
const eslintrc = fs.readFileSync('.eslintrc', 'utf8')
await runInTemporaryDir(async () => {
fs.writeFileSync('README.md', example.replace('a + b', 'a+b'))
fs.writeFileSync('.eslintrc', eslintrc)
await buildCommand.handler({ })
assert(fs.existsSync('src/add.js'))
assert(fs.existsSync('lib/add.js'))
await testCommand.handler({ })
assert(fs.existsSync('coverage/lcov.info'))
assert(fs.readFileSync('README.md', 'utf8') !== example)
await lintCommand.handler({ _: ['fix'] })
assert(fs.readFileSync('README.md', 'utf8') === example)
assertProcessExitCode(1)
lintCommand.allowToUseESLint(false)
})
})
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)
}
}
// cli/index.js
import * as buildCommand from './buildCommand'
import * as testCommand from './testCommand'
import * as lintCommand from './lintCommand'
export function main () {
// XXX: Work around yargs’ lack of default command support.
const commands = [ buildCommand, testCommand, lintCommand ]
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
})
}
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
// 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