@eluvio/elv-ramdoc
Version:
A customized version of the JSDoc template used by Ramda's API docs
520 lines (479 loc) • 16 kB
JavaScript
const _ELV_RAMDOC_DEBUG = process.env.ELV_RAMDOC_DEBUG
const fs = require('fs')
const fsExtra = require('fs-extra')
const Path = require('path') // can't use lower-case 'path' because of name collision with ramda
const helper = require('jsdoc/util/templateHelper')
const Highlight = require('highlight.js')
const madge = require('madge')
const marked = require('marked')
const pug = require('pug')
const {
applySpec,
ascend,
chain,
defaultTo,
filter,
head,
identity,
join,
map,
path,
pipe,
prop,
propEq,
replace,
sortBy,
sortWith,
split,
toUpper,
values
} = require('@eluvio/ramda-fork')
/**
* Copies a directory's contents recursively, creating the destination directory if needed
*
* @function
* @since v0.0.1
* @category File
* @private
* @sig (String, String) -> undefined
* @param {String} sourceDir - Directory from which to copy contents
* @param {String} destDir - Directory to copy to
* @returns {undefined}
*
* @example
*
* _copyDir(
* '/Users/foo/elv-ramdoc/node_modules/ramda/dist',
* '/Users/foo/elv-ramdoc/docs/js',
* ) //=> undefined
*
*/
const _copyDir = (sourceDir, destDir) => {
// create dir if it doesn't exist
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, {recursive: true})
// copy contents
fsExtra.copy(
sourceDir,
destDir,
err => {
if (err) throw Error(err)
}
)
}
const _dependencies = async (inputPath, entryPoint) => {
console.log(`inputPath=${inputPath}`)
console.log(`entryPoint=${entryPoint}`)
const result = await madge(
inputPath,
{
excludeRegExp: [new RegExp('^' + _escapeRegExp(entryPoint) + '$')]
}
)
return result.obj()
}
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const _escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
const _isDir = pathString => fs.statSync(pathString).isDirectory();
/**
* Converts text in [Markdown](https://www.markdownguide.org/) format to an HTML string
*
* @function
* @since v0.0.1
* @category String
* @private
* @sig String -> String
* @param {String} mdString - text in Markdown format
* @returns {String} HTML text
*
* @example
*
* _markdownToHtml('#foo') //=> '<h1 id="foo">foo</h1>'
*
*/
const _markdownToHtml = function (mdString) {
return marked.parse(mdString)
}
const _exampleSplitMultilineOutput = line => {
const slashReplacements = {'\\\\': '\\', '\\n': '\n', '\\"': '"'};
function slashUnescape(contents) {
return contents.replace(/\\([\\n"])/g, function(replace) {
return slashReplacements[replace];
});
}
const regex = /^(.+)\/\/(=> +OUTPUT: +`)(.+)` *$/
const match = line.match(regex)
if(match){
const indentText = ' '.repeat(match[1].length) + '//' + ' '.repeat(match[2].length)
const outputLines = slashUnescape(match[3]).split('\n')
const line1 = match[1] + '//' + match[2] + outputLines[0]
return outputLines.map((x, i) => i===0 ? line1 : indentText + x).join('\n') + '`'
} else {
return line
}
}
/**
* Formats example Javascript code using [highlight.js](https://highlightjs.org/) syntax highlighting
*
* @function
* @since v0.0.1
* @category String
* @private
* @sig String | [String] -> String
* @param {(String | String[])} - string or array of strings containing lines of code
* @returns {String} HTML text
*
* @example
*
* _prettifyCode('console.log(x)') //=> '<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(x)'
*
*/
const _prettifyCode = pipe(
join('\n'),
split('\n'),
map(_exampleSplitMultilineOutput),
join('\n'),
s => Highlight.highlight(s, {language: 'javascript'}).value
)
/**
* Replaces '...' with '…' and '->' with '→'.
* Used for processing @sig tags
*
* @function
* @since v0.0.1
* @category String
* @private
* @sig String -> String
* @param {String} - input string (@sig tag contents)
* @returns {String} String with symbols substituted in
*
* @example
*
* _prettifySig('a -> b') //=> 'a → b'
*
*/
const _prettifySig = pipe(
replace(/[.][.][.]/g, '\u2026'),
replace(/->/g, '\u2192')
)
/**
* Splits each string in an array by comma (excising whitespace in the process), keeping the array flat.
* Used to simplify handling of @see tag listing multiple items, where items might be listed on same line
* (separated by commas) on multiple lines.
*
* @function
* @since v0.0.1
* @category Array
* @private
* @sig [String] -> [String]
* @param {String[]} - @see tag contents as string array
* @returns {String[]} String array with each item in a separate element, regardless of whether it was on a new line or
* was on same line as another item but separated by a comma
*
* @example
*
* _simplifySee(
* [
* 'foo, bar',
* 'baz'
* ]
* ) //=> [ 'foo', 'bar', 'baz' ]
*
*/
const _simplifySee = chain(split(/\s*,\s*/))
/**
* Filters array of objects based on `title` attribute.
*
* @function
* @since v0.0.1
* @category Array
* @private
* @sig String -> [Object] -> [Object]
* @param {String} - The string to filter by
* @param {Object[]} - Array of objects to filter by 'title' attribute
* @returns {Object[]} The filtered object array
*
* @example
*
* _titleFilter('see')(
* [
* {title: 'since'},
* {title: 'see'}
* ]
* ) //=> [{title: 'see'}]
*
*/
const _titleFilter = pipe(propEq('title'), filter)
/**
* Extracts 'value' property from an array of objects, flattening one level if 'value' is an array
*
* @function
* @since v0.0.1
* @category Array
* @private
* @sig [Object] -> [*]
* @param {Object[]} - Array of objects
* @returns {Array} Extracted `value` property from each object
*
* @example
*
* _valueProp(
* [
* {value: 'a'},
* {value: ['b', 'c']}
* ]
* ) //=> ['a', 'b', 'c']
*
*/
const _valueProp = chain(prop('value'))
/**
* Returns a function that converts an array of JSDoc data objects to an array of objects tailored for our
* [pug](https://pugjs.org/api/getting-started.html) template.
*
* @function
* @since v0.0.1
* @category Array
* @private
* @sig String -> ([Object] -> [Object])
* @param {String} baseDir - base directory of project
* @returns {Function} Function that accepts and array of objects and returns an array of new objects
*
*/
const _simplifyData = baseDir => applySpec({ // REMAP: create a new object by slicing and dicing input object
access: pipe( // create 'access' attribute containing 'public' or 'private'
prop('access'),
defaultTo('public')
),
category: pipe( // create 'category' attribute
prop('tags'),
_titleFilter('category'),
_valueProp,
head,
defaultTo('')
),
curried: pipe( // create 'curried' attribute
prop('tags'),
_titleFilter('curried'),
head,
propEq('text', '')
),
deprecated: pipe( // create 'deprecated' attribute
prop('deprecated'),
defaultTo('')
),
description: pipe( // create 'description' attribute
prop('description'),
defaultTo(''),
_markdownToHtml
),
example: pipe( // create 'example' attribute
prop('examples'),
defaultTo(['']),
_prettifyCode
),
filePath: x => Path.resolve( // create 'filePath' attribute
'/',
Path.relative(
baseDir,
Path.join(
path(['meta', 'path'], x),
path(['meta', 'filename'], x)
)
)
),
lineno: path(['meta', 'lineno']), // create 'lineno' attribute
name: pipe( // create 'name' attribute
prop('name'),
defaultTo('')
),
params: pipe( // create 'params' attribute
prop('params'),
defaultTo([]),
map(applySpec({
description: pipe(
prop('description'),
defaultTo(''),
_markdownToHtml
),
name: pipe(
prop('name'),
defaultTo('')
),
type: pipe(
path(['type', 'names', 0]),
defaultTo('')
)
}))
),
returns: { // create 'returns' attribute
description: pipe(
path(['returns', 0, 'description']),
defaultTo('')
),
type: pipe(
path(['returns', 0, 'type', 'names', 0]),
defaultTo('')
)
},
see: pipe( // create 'see' attribute
prop('see'),
defaultTo(''),
_simplifySee
),
sigs: pipe(
prop('tags'), // create 'sigs' attribute
_titleFilter('sig'),
_valueProp,
map(_prettifySig)
),
since: pipe( // create 'since' attribute
prop('since'),
defaultTo('')
),
type: path(['type', 'names', 0]), // create 'type' attribute (used by @constant)
value: prop('defaultvalue'), // create 'value' attribute (used by @constant)
original: identity
})
//TODO: add pre-check for needed info in package.json
/**
* Function called by JSDoc to generate the final documentation files.
*
* Empties target directory specified in `opts.destination` then populates with documentation files generated from
* `data` passed in by JSDoc.
*
* @function
* @since v0.0.1
* @category File
* @sig ([Object], Object) -> undefined
* @param {Object} data - [TaffyDB database](https://taffydb.com) passed in by JSDoc
* @param {Object} opts - Options info passed in by JSDoc
* @param {Array} opts._ - List of files/directories to scan
* @param {String} opts.readme - Text to use for README page
* @param {String} opts.configure - Path to JSDoc configuration file, e.g. `".jsdoc.json"`
* @param {String} opts.destination - The path to the output folder for the generated documentation.
* @param {String} opts.encoding - Assume this encoding when reading all source files. Defaults to `"utf8"`.
* @param {Boolean} opts.pedantic - Treat errors as fatal errors, and treat warnings as errors.
* @param {Boolean} opts.private - Include symbols marked with the `@private` tag in the generated documentation.
* @param {Boolean} opts.recurse - Recurse into subdirectories when scanning for source files and tutorials
* @param {String} opts.template - The path to the template directory to use for generating output (the directory containing the `publish.js` script and other needed files)
* @returns {undefined}
*
*/
exports.publish = (data, opts) => {
const baseDir = Path.dirname(Path.resolve(opts.configure))
const packageJsonPath = Path.join(baseDir, 'package.json')
const packageJSON = require(packageJsonPath)
if (!path(['repository', 'url'], packageJSON)) throw Error('.repository.url not found in package.json')
if (!opts.destination.includes('docs')) throw Error('Expected to find "docs" in the destination path. This is a safety check to try to prevent accidental emptying of the wrong directory.')
// get info needed to generate dependency graphs
const inputPath = Path.basename(baseDir) === opts["_"][0] ? baseDir : Path.join(baseDir, opts["_"][0])
const entryPoint = _isDir(inputPath) ? packageJSON.main : ""
_dependencies(inputPath, entryPoint).then(
result => {
if (_ELV_RAMDOC_DEBUG) {
console.group('INTERNAL DEPENDENCIES:')
console.log(`items analyzed for dependencies: ${Object.keys(result).length}`)
console.log(`internal dependency links: ${values(result).reduce(
(accumulator, element) => accumulator + element.length,
0
)}`)
console.groupEnd()
console.log()
}
return result
}
)
// delete any previous files
fsExtra.emptyDirSync(Path.resolve(opts.destination))
// copy static assets
_copyDir(Path.resolve(__dirname, 'images'), Path.resolve(opts.destination, 'images'))
_copyDir(Path.resolve(__dirname, 'js'), Path.resolve(opts.destination, 'js'))
_copyDir(Path.resolve(Path.join(__dirname, 'node_modules', '@eluvio', 'ramda-fork','dist')), Path.resolve(opts.destination, 'js/ramda-fork'))
_copyDir(Path.resolve(Path.join(__dirname, 'node_modules', '@popperjs', 'core','dist', 'umd')), Path.resolve(opts.destination, 'js/popper-core'))
_copyDir(Path.resolve(Path.join(__dirname, 'node_modules','clipboard','dist')), Path.resolve(opts.destination, 'js/clipboard'))
_copyDir(Path.resolve(Path.join(__dirname, 'node_modules','tippy.js','dist')), Path.resolve(opts.destination, 'js/tippy.js'))
_copyDir(Path.resolve(__dirname, 'css'), Path.resolve(opts.destination, 'css'))
if (_ELV_RAMDOC_DEBUG) {
console.group('raw data:')
console.log(JSON.stringify(data().get(), null, 2))
console.groupEnd()
console.group('opts:')
console.log(JSON.stringify(opts, null, 2))
console.groupEnd()
}
let undocumentedItems = data()
.get()
.filter(
i => i.undocumented
&& i.scope === 'global'
&& i.name === i.meta.code.name
&& ['constant', 'function'].includes(i.kind)
&& i.meta.code.type !== 'CallExpression'
&& i.meta.filename !== entryPoint // assumes that main entry point is just a packaging wrapper, no docs expected
)
.map(
i => Object(
{
name: i.name,
location: Path.join(i.meta.path, i.meta.filename)
+ ':' + i.meta.lineno
+ ':' + i.meta.columnno,
codetype: i.meta.code.type,
kind: i.kind
}
)
)
undocumentedItems = sortWith(
[
ascend(prop('location')),
ascend('name'),
],
undocumentedItems
)
const prunedData = helper.prune(data)()
if (_ELV_RAMDOC_DEBUG) {
console.log('---------')
console.log('pruned data:')
console.log('---------')
console.log(JSON.stringify(prunedData.get(), null, 2))
console.log()
}
const filteredData = sortBy(x => x && toUpper(`${x.name}`), prunedData.get())
.filter(x => ['function', 'constant', 'class'].includes(x.kind))
.filter(x => opts.private || (x.access !== 'private')) // filter out private items if opts.private is false
// noinspection JSValidateTypes
const docs = filteredData.map(_simplifyData(baseDir)) // tailor for our template
if (_ELV_RAMDOC_DEBUG) {
console.group('filtered data:')
console.log(JSON.stringify(filteredData, null, 2))
console.groupEnd()
console.group('Filtered and reprocessed docs:')
console.log(JSON.stringify(docs, null, 2))
console.groupEnd()
console.log()
console.group('packageJSON:')
console.log(JSON.stringify(packageJSON, null, 2))
console.groupEnd()
console.log()
}
// Convert url reference in packageJSON.repository.url to remove git+ and .git if needed
const processGithubUrl = url => url.replace(/^git\+/,'').replace(/\.git$/,'')
const context = {
baseDir,
docs,
docNames: docs.map(prop('name')),
opts,
packageJSON,
processGithubUrl
}
const templateFileIndex = Path.resolve(__dirname, 'pug', 'index.pug')
const outputContentIndex = pug.renderFile(templateFileIndex, context)
const outputFileIndex = Path.resolve(opts.destination, 'index.html')
fs.writeFileSync(outputFileIndex, outputContentIndex, {encoding: 'utf8'})
const templateFileAPI = Path.resolve(__dirname,'pug', 'api.pug')
const outputContentAPI = pug.renderFile(templateFileAPI, context)
const outputFileAPI = Path.resolve(opts.destination, 'api.html')
fs.writeFileSync(outputFileAPI, outputContentAPI, {encoding: 'utf8'})
console.group('UNDOCUMENTED ITEMS:')
undocumentedItems.map(i => console.log(i.name + ' (' + i.kind + ')\n' + i.location + '\n'))
console.groupEnd()
}