var arrayify = require('array-back') var util = require('util') var handlebars = require('handlebars') var marked = require('marked') var objectGet = require('object-get') var where = require('test-value').where var flatten = require('reduce-flatten') var state = require('../lib/state') /** * ddata is a collection of handlebars helpers for working with the documentation data output by [jsdoc-parse](https://github.com/75lb/jsdoc-parse). * @module * @example * ```js * var handlebars = require("handlebars") * var ddata = require("ddata") * var docs = require("./docs.json") // jsdoc-parse output * * handlebars.registerHelper(ddata) * var template = * "{{#module name='yeah-module'}}\ * The author of the module is: {{author}}.\ * {{/module}}" * console.log(handlebars.compile(template)(docs)) * ``` */ /* utility block helpers */ exports.link = link exports.returnSig2 = returnSig2 exports.sig = sig exports.children = children exports.indexChildren = indexChildren /* helpers which return objects */ exports._link = _link /* helpers which return booleans */ exports.isClass = isClass exports.isClassMember = isClassMember exports.isConstructor = isConstructor exports.isFunction = isFunction exports.isConstant = isConstant exports.isEvent = isEvent exports.isEnum = isEnum exports.isTypedef = isTypedef exports.isCallback = isCallback exports.isModule = isModule exports.isMixin = isMixin exports.isExternal = isExternal exports.isPrivate = isPrivate exports.isProtected = isProtected exports.showMainIndex = showMainIndex /* helpers which return lists */ exports._orphans = _orphans exports._identifiers = _identifiers exports._children = _children exports._globals = _globals exports.descendants = descendants /* helpers which return single identifiers */ exports.exported = exported exports.parentObject = parentObject exports._identifier = _identifier /* helpers which return strings */ exports.anchorName = anchorName exports.md = md exports.md2 = md2 exports.methodSig = methodSig exports.parseLink = parseLink exports.parentName = parentName exports.option = option exports.optionEquals = optionEquals exports.optionSet = optionSet exports.optionIsSet = optionIsSet exports.stripNewlines = stripNewlines /* helpers which keep state */ exports.headingDepth = headingDepth exports.depth = depth exports.depthIncrement = depthIncrement exports.depthDecrement = depthDecrement exports.indexDepth = indexDepth exports.indexDepthIncrement = indexDepthIncrement exports.indexDepthDecrement = indexDepthDecrement /** * omits externals without a description * @static */ function _globals (options) { options.hash.scope = 'global' return _identifiers(options).filter(function (identifier) { if (identifier.kind === 'external') { return identifier.description && identifier.description.length > 0 } else { return true } }) } /** * This helper is a duplicate of the handlebars `each` helper with the one exception that `depthIncrement` is called on each iteration. * @category Block helper: selector */ function children (options) { var context = _children.call(this, options) var fn = options.fn var inverse = options.inverse var i = 0 var ret = '' var data var contextPath if (options.data) { data = handlebars.createFrame(options.data) } for (var j = context.length; i < j; i++) { depthIncrement(options) if (data) { data.index = i data.first = (i === 0) data.last = (i === (context.length - 1)) if (contextPath) { data.contextPath = contextPath + i } } ret = ret + fn(context[i], { data: data }) depthDecrement(options) } if (i === 0) { ret = inverse(this) } return ret } /** * This helper is a duplicate of the handlebars `each` helper with the one exception that `indexDepthIncrement` is called on each iteration. * @static * @category Block helper: selector */ function indexChildren (options) { var context = _children.call(this, options) var fn = options.fn var inverse = options.inverse var i = 0 var ret = '' var data var contextPath if (options.data) { data = handlebars.createFrame(options.data) } for (var j = context.length; i < j; i++) { indexDepthIncrement(options) if (data) { data.index = i data.first = (i === 0) data.last = (i === (context.length - 1)) if (contextPath) { data.contextPath = contextPath + i } } ret = ret + fn(context[i], { data: data }) indexDepthDecrement(options) } if (i === 0) { ret = inverse(this) } return ret } /** * @param id {string} - the ID to link to, e.g. `external:XMLHttpRequest`, `GlobalClass#propOne` etc. * @static * @category Block helper: util * @example * {{#link "module:someModule.property"~}} * {{name}} {{!-- prints 'property' --}} * {{url}} {{!-- prints 'module-someModule-property' --}} * {{/link}} */ function link (longname, options) { return options.fn(_link(longname, options)) } /** * e.g. namepaths `module:Something` or type expression `Array.` * @static * @param {string} - namepath or type expression * @param {object} - the handlebars helper options object */ function _link (input, options) { if (typeof input !== 'string') return null var linked, matches, namepath var output = {} /* test input for 1. A type expression containing a namepath, e.g. Array. 2. a namepath referencing an `id` 3. a namepath referencing a `longname` */ if ((matches = input.match(/.*?<(.*?)>/))) { namepath = matches[1] } else { namepath = input } options.hash = { id: namepath } linked = _identifier(options) if (!linked) { options.hash = { longname: namepath } linked = _identifier(options) } if (!linked) { output = { name: input, url: null } } else { output.name = input.replace(namepath, linked.name) if (isExternal.call(linked)) { if (linked.description) { output.url = '#' + anchorName.call(linked, options) } else { if (linked.see && linked.see.length) { var firstLink = parseLink(linked.see[0])[0] output.url = firstLink ? firstLink.url : linked.see[0] } else { output.url = null } } } else { output.url = '#' + anchorName.call(linked, options) } } return output } /** @static @returns {{symbol: string, types: array}} @category Block helper: util */ function returnSig2 (options) { if (!isConstructor.call(this)) { if (this.returns) { var typeNames = arrayify(this.returns).map(function (ret) { return ret.type && ret.type.names }) typeNames = typeNames.filter(function (name) { return name }) if (typeNames.length) { return options.fn({ symbol: '⇒', types: typeNames.reduce(flatten, []) }) } else { return options.fn({ symbol: null, types: null }) } } else if ((this.type || this.kind === 'namespace') && this.kind !== 'event') { if (this.kind === 'namespace') { return options.fn({ symbol: ':', types: ['object'] }) } else { return options.fn({ symbol: ':', types: this.type.names }) } } } } /** @category Block helper: util */ function sig (options) { var data if (options.data) { data = handlebars.createFrame(options.data || {}) } data.prefix = this.kind === 'constructor' ? 'new' : '' data.parent = null data.accessSymbol = null data.name = isEvent.call(this) ? '"' + this.name + '"' : this.name data.methodSign = null data.returnSymbol = null data.returnTypes = null data.suffix = this.isExported ? '⏏' : isPrivate.call(this) ? '℗' : '' data.depOpen = null data.depClose = null data.codeOpen = null data.codeClose = null var mSig = methodSig.call(this) if (isConstructor.call(this) || isFunction.call(this)) { data.methodSign = '(' + mSig + ')' } else if (isEvent.call(this)) { if (mSig) data.methodSign = '(' + mSig + ')' } if (!isEvent.call(this)) { data.parent = parentName.call(this, options) data.accessSymbol = (this.scope === 'static' || this.scope === 'instance') ? '.' : this.scope === 'inner' ? '~' : '' } if (!isConstructor.call(this)) { if (this.returns) { data.returnSymbol = '⇒' var typeNames = arrayify(this.returns) .map(function (ret) { return ret.type && ret.type.names }) .filter(function (name) { return name }) if (typeNames.length) { data.returnTypes = typeNames.reduce(flatten, []) } } else if ((this.type || this.kind === 'namespace') && this.kind !== 'event') { data.returnSymbol = ':' if (isEnum.call(this)) { data.returnTypes = ['enum'] } else if (this.kind === 'namespace') { data.returnTypes = ['object'] } else { data.returnTypes = this.type.names } } else if (this.chainable) { data.returnSymbol = '↩︎' } else if (this.augments) { data.returnSymbol = '⇐' data.returnTypes = [this.augments[0]] } } if (this.deprecated) { if (optionEquals('no-gfm', true, options) || options.hash['no-gfm']) { data.depOpen = '' data.depClose = '' } else { data.depOpen = '~~' data.depClose = '~~' } } if (option('name-format', options) && !isClass.call(this) && !isModule.call(this)) { data.codeOpen = '`' data.codeClose = '`' } return options.fn(this, { data: data }) } /** @this {identifier} @returns {boolean} @alias module:ddata.isClass */ function isClass () { return this.kind === 'class' } /** returns true if the parent of the current identifier is a class @returns {boolean} @static */ function isClassMember (options) { var parent = arrayify(options.data.root).find(where({ id: this.memberof })) if (parent) { return parent.kind === 'class' } } function isConstructor () { return this.kind === 'constructor' } function isFunction () { return this.kind === 'function' } function isConstant () { return this.kind === 'constant' } /** returns true if this is an event @returns {boolean} @static */ function isEvent () { return this.kind === 'event' } function isEnum () { return this.isEnum || this.kind === 'enum' } function isExternal () { return this.kind === 'external' } function isTypedef () { return this.kind === 'typedef' && this.type.names[0] !== 'function' } function isCallback () { return this.kind === 'typedef' && this.type.names[0] === 'function' } function isModule () { return this.kind === 'module' } function isMixin () { return this.kind === 'mixin' } function isPrivate () { return this.access === 'private' } function isProtected () { return this.access === 'protected' } /** True if there at least two top-level identifiers (i.e. globals or modules) @category returns boolean @returns {boolean} @static */ function showMainIndex (options) { return _orphans(options).length > 1 } /** * Returns an array of the top-level elements which have no parents. Output only includes externals which have a description. * @returns {array} * @static * @category Returns list */ function _orphans (options) { options.hash.memberof = undefined return _identifiers(options).filter(function (identifier) { if (identifier.kind === 'external') { return identifier.description && identifier.description.length > 0 } else { return true } }) } /** * Returns an array of identifiers matching the query * @returns {array} * @static */ function _identifiers (options) { var query = {} for (var prop in options.hash) { if (/^-/.test(prop)) { query[prop.replace(/^-/, '!')] = options.hash[prop] } else if (/^_/.test(prop)) { query[prop.replace(/^_/, '')] = new RegExp(options.hash[prop]) } else { query[prop] = options.hash[prop] } } return arrayify(options.data.root).filter(where(query)).filter(function (doclet) { return !doclet.ignore && (state.options.private ? true : doclet.access !== 'private') }) } /** return the identifiers which are a `memberof` this one. Exclude externals without descriptions. @param [sortBy] {string} - "kind" @param [min] {number} - only returns if there are `min` children @this {identifier} @returns {identifier[]} @static */ function _children (options) { if (!this.id) return [] var min = options.hash.min delete options.hash.min options.hash.memberof = this.id var output = _identifiers(options) output = output.filter(function (identifier) { if (identifier.kind === 'external') { return identifier.description && identifier.description.length > 0 } else { return true } }) if (output.length >= (min || 0)) return output } /** return a flat list containing all decendants @param [sortBy] {string} - "kind" @param [min] {number} - only returns if there are `min` children @this {identifier} @returns {identifier[]} @static */ function descendants (options) { var min = typeof options.hash.min !== 'undefined' ? options.hash.min : 2 delete options.hash.min options.hash.memberof = this.id var output = [] function iterate (childrenList) { if (childrenList.length) { childrenList.forEach(function (child) { output.push(child) iterate(_children.call(child, options)) }) } } iterate(_children.call(this, options)) if (output.length >= (min || 0)) return output } /** returns the exported identifier of this module @this {identifier} - only works on a module @returns {identifier} @static */ function exported (options) { var exp = arrayify(options.data.root).find(where({ '!kind': 'module', id: this.id })) return exp || this } /** Returns an identifier matching the query @static */ function _identifier (options) { return _identifiers(options)[0] } /** Returns the parent @static */ function parentObject (options) { return arrayify(options.data.root).find(where({ id: this.memberof })) } /** returns a unique ID string suitable for use as an `href`. @this {identifier} @returns {string} @static @category Returns string @example ```js > ddata.anchorName.call({ id: "module:yeah--Yeah()" }) 'module_yeah--Yeah_new' ``` */ function anchorName (options) { if (!this.id) throw new Error('[anchorName helper] cannot create a link without a id: ' + JSON.stringify(this)) if (this.inherited) { options.hash.id = this.inherits var inherits = _identifier(options) if (inherits) { return anchorName.call(inherits, options) } else { return '' } } return util.format( '%s%s%s', this.isExported ? 'exp_' : '', this.kind === 'constructor' ? 'new_' : '', this.id .replace(/:/g, '_') .replace(/~/g, '..') .replace(/\(\)/g, '_new') .replace(/#/g, '+') ) } /** converts the supplied text to markdown @static @category Returns string */ function md (string, options) { if (string) { var result = marked(string).replace('lang-js', 'language-javascript') return result } } function md2 (options) { return marked.inlineLexer(options.fn(this).toString(), []) } /** Returns the method signature, e.g. `(options, [onComplete])` @this {identifier} @returns {string} @category Returns string @static */ function methodSig () { return arrayify(this.params).filter(function (param) { return param.name && !/\./.test(param.name) }).map(function (param) { if (param.variable) { return param.optional ? '[...' + param.name + ']' : '...' + param.name } else { return param.optional ? '[' + param.name + ']' : param.name } }).join(', ') } /** * extracts url and caption data from @link tags * @param {string} - a string containing one or more {@link} tags * @returns {Array.<{original: string, caption: string, url: string}>} * @static */ function parseLink (text) { if (!text) return '' var results = [] var matches = null var link1 = /{@link\s+([^\s}|]+?)\s*}/g // {@link someSymbol} var link2 = /\[([^\]]+?)\]{@link\s+([^\s}|]+?)\s*}/g // [caption here]{@link someSymbol} var link3 = /{@link\s+([^\s}|]+?)\s*\|([^}]+?)}/g // {@link someSymbol|caption here} var link4 = /{@link\s+([^\s}|]+?)\s+([^}|]+?)}/g // {@link someSymbol Caption Here} while ((matches = link4.exec(text)) !== null) { results.push({ original: matches[0], caption: matches[2], url: matches[1] }) text = text.replace(matches[0], ' '.repeat(matches[0].length)) } while ((matches = link3.exec(text)) !== null) { results.push({ original: matches[0], caption: matches[2], url: matches[1] }) text = text.replace(matches[0], ' '.repeat(matches[0].length)) } while ((matches = link2.exec(text)) !== null) { results.push({ original: matches[0], caption: matches[1], url: matches[2] }) text = text.replace(matches[0], ' '.repeat(matches[0].length)) } while ((matches = link1.exec(text)) !== null) { results.push({ original: matches[0], caption: matches[1], url: matches[1] }) text = text.replace(matches[0], ' '.repeat(matches[0].length)) } return results } /** returns the parent name, instantiated if necessary @this {identifier} @returns {string} @static */ function parentName (options) { function instantiate (input) { if (/^[A-Z]{3}/.test(input)) { return input.replace(/^([A-Z]+)([A-Z])/, function (str, p1, p2) { return p1.toLowerCase() + p2 }) } else { return input.charAt(0).toLowerCase() + input.slice(1) } } /* don't bother with a parentName for exported identifiers */ if (this.isExported) return '' if (this.memberof && this.kind !== 'constructor') { var parent = arrayify(options.data.root).find(where({ id: this.memberof })) if (parent) { if (this.scope === 'instance') { var name = parent.typicalname || parent.name return instantiate(name) } else if (this.scope === 'static' && !(parent.kind === 'class' || parent.kind === 'constructor')) { return parent.typicalname || parent.name } else { return parent.name } } else { return this.memberof } } } /** returns a dmd option, e.g. "sort-by", "heading-depth" etc. @static */ function option (name, options) { return objectGet(options.data.root.options, name) } /** @static */ function optionEquals (name, value, options) { return options.data.root.options[name] === value } /** @static */ function optionSet (name, value, options) { options.data.root.options[name] = value } /** @static */ function optionIsSet (name, options) { return options.data.root.options[name] !== undefined } /** @static */ function stripNewlines (input) { if (input) return input.replace(/[\r\n]+/g, ' ') } /** @static */ function headingDepth (options) { return options.data.root.options._depth + (options.data.root.options['heading-depth']) } /** @static */ function depth (options) { return options.data.root.options._depth } /** @static */ function depthIncrement (options) { options.data.root.options._depth++ } /** @static */ function depthDecrement (options) { options.data.root.options._depth-- } /** @static */ function indexDepth (options) { return options.data.root.options._indexDepth } /** @static */ function indexDepthIncrement (options) { options.data.root.options._indexDepth++ } /** @static */ function indexDepthDecrement (options) { options.data.root.options._indexDepth-- }