From deb6c093a8d1180df786fb3efef7b4eeb8bca45f Mon Sep 17 00:00:00 2001 From: ahocevar Date: Tue, 23 Oct 2018 18:02:55 +0200 Subject: [PATCH] Update plugin chain to support JSDoc import types --- config/jsdoc/api/conf.json | 6 +- config/jsdoc/api/plugins/api.js | 16 +- config/jsdoc/api/plugins/convert-types.js | 139 ++++++++++++++++++ config/jsdoc/api/plugins/inheritdoc.js | 109 ++++++++++++++ config/jsdoc/api/plugins/normalize-exports.js | 106 ------------- .../jsdoc/api/plugins/normalize-longnames.js | 21 +++ config/jsdoc/api/plugins/typedefs.js | 85 ----------- 7 files changed, 280 insertions(+), 202 deletions(-) create mode 100644 config/jsdoc/api/plugins/convert-types.js create mode 100755 config/jsdoc/api/plugins/inheritdoc.js delete mode 100644 config/jsdoc/api/plugins/normalize-exports.js create mode 100644 config/jsdoc/api/plugins/normalize-longnames.js delete mode 100644 config/jsdoc/api/plugins/typedefs.js diff --git a/config/jsdoc/api/conf.json b/config/jsdoc/api/conf.json index 0b5b66be93..5aa84df9eb 100644 --- a/config/jsdoc/api/conf.json +++ b/config/jsdoc/api/conf.json @@ -7,7 +7,7 @@ "allowUnknownTags": true }, "source": { - "includePattern": ".+\\.js(doc)?$", + "includePattern": ".+\\.js$", "excludePattern": "(^|\\/|\\\\)_", "include": [ "src/ol" @@ -15,8 +15,10 @@ }, "plugins": [ "plugins/markdown", - "config/jsdoc/api/plugins/normalize-exports", + "config/jsdoc/api/plugins/convert-types", + "config/jsdoc/api/plugins/normalize-longnames", "config/jsdoc/api/plugins/inline-options", + "config/jsdoc/api/plugins/inheritdoc", "config/jsdoc/api/plugins/events", "config/jsdoc/api/plugins/observable", "config/jsdoc/api/plugins/api" diff --git a/config/jsdoc/api/plugins/api.js b/config/jsdoc/api/plugins/api.js index 0f1788dba8..b283ab35b4 100644 --- a/config/jsdoc/api/plugins/api.js +++ b/config/jsdoc/api/plugins/api.js @@ -95,17 +95,15 @@ exports.handlers = { newDoclet: function(e) { const doclet = e.doclet; if (doclet.stability) { - modules[doclet.longname.split('~').shift()] = true; + modules[doclet.longname.split(/[~\.]/).shift()] = true; api.push(doclet); } - // Mark explicity defined namespaces - needed in parseComplete to keep - // namespaces that we need as containers for api items. - if (/.*\.jsdoc$/.test(doclet.meta.filename) && doclet.kind == 'namespace') { - doclet.namespace_ = true; - } if (doclet.kind == 'class') { - modules[doclet.longname.split('~').shift()] = true; - classes[doclet.longname] = doclet; + modules[doclet.longname.split(/[~\.]/).shift()] = true; + if (!(doclet.longname in classes)) { + classes[doclet.longname] = doclet; + } + } if (doclet.name === doclet.longname && !doclet.memberof) { // Make sure anonymous default exports are documented doclet.setMemberof(doclet.longname); @@ -116,7 +114,7 @@ exports.handlers = { const doclets = e.doclets; for (let i = doclets.length - 1; i >= 0; --i) { const doclet = doclets[i]; - if (doclet.stability || doclet.namespace_) { + if (doclet.stability) { if (doclet.kind == 'class') { includeAugments(doclet); } diff --git a/config/jsdoc/api/plugins/convert-types.js b/config/jsdoc/api/plugins/convert-types.js new file mode 100644 index 0000000000..effc1a4b3b --- /dev/null +++ b/config/jsdoc/api/plugins/convert-types.js @@ -0,0 +1,139 @@ +const path = require('path'); +const fs = require('fs'); + +const importRegEx = /(typeof )?import\("([^"]*)"\)\.([^ \.\|\}><,\)=\n]*)([ \.\|\}><,\)=\n])/g; +const typedefRegEx = /@typedef \{[^\}]*\} ([^ \r?\n?]*)/; + +const defaultExports = {}; +const fileNodes = {}; + +function getDefaultExportName(moduleId, parser) { + if (!defaultExports[moduleId]) { + if (!fileNodes[moduleId]) { + const classDeclarations = {}; + const absolutePath = path.join(process.cwd(), 'src', moduleId + '.js'); + const file = fs.readFileSync(absolutePath, 'UTF-8'); + const node = fileNodes[moduleId] = parser.astBuilder.build(file, absolutePath); + if (node.program && node.program.body) { + const nodes = node.program.body; + for (let i = 0, ii = nodes.length; i < ii; ++i) { + const node = nodes[i]; + if (node.type === 'ClassDeclaration') { + classDeclarations[node.id.name] = node; + } else if (node.type === 'ExportDefaultDeclaration') { + const classDeclaration = classDeclarations[node.declaration.name]; + if (classDeclaration) { + defaultExports[moduleId] = classDeclaration.id.name; + } + } + } + } + } + } + if (!defaultExports[moduleId]) { + defaultExports[moduleId] = ''; + } + return defaultExports[moduleId]; +} + +exports.astNodeVisitor = { + + visitNode: function(node, e, parser, currentSourceName) { + if (node.type === 'File') { + const modulePath = path.relative(path.join(process.cwd(), 'src'), currentSourceName).replace(/\.js$/, ''); + fileNodes[modulePath] = node; + const identifiers = {}; + if (node.program && node.program.body) { + const nodes = node.program.body; + for (let i = 0, ii = nodes.length; i < ii; ++i) { + let node = nodes[i]; + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + node = node.declaration; + } + if (node.type === 'ImportDeclaration') { + node.specifiers.forEach(specifier => { + let defaultImport = false; + switch (specifier.type) { + case 'ImportDefaultSpecifier': + defaultImport = true; + // fallthrough + case 'ImportSpecifier': + identifiers[specifier.local.name] = { + defaultImport, + value: node.source.value + }; + break; + default: + } + }); + } else if (node.type === 'ClassDeclaration') { + if (node.id && node.id.name) { + identifiers[node.id.name] = { + value: path.basename(currentSourceName) + }; + } + + // Add class inheritance information because JSDoc does not honor + // the ES6 class's `extends` keyword + if (node.superClass && node.leadingComments) { + const leadingComment = node.leadingComments[node.leadingComments.length - 1]; + const lines = leadingComment.value.split(/\r?\n/); + lines.push(lines[lines.length - 1]); + const identifier = identifiers[node.superClass.name]; + if (identifier) { + const absolutePath = path.resolve(path.dirname(currentSourceName), identifier.value); + const moduleId = path.relative(path.join(process.cwd(), 'src'), absolutePath).replace(/\.js$/, ''); + const exportName = identifier.defaultImport ? getDefaultExportName(moduleId, parser) : node.superClass.name; + lines[lines.length - 2] = ' * @extends ' + `module:${moduleId}${exportName ? '~' + exportName : ''}`; + } else { + lines[lines.length - 2] = ' * @extends ' + node.superClass.name; + } + leadingComment.value = lines.join('\n'); + } + + } + } + } + if (node.comments) { + node.comments.forEach(comment => { + //TODO Handle typeof, to indicate that a constructor instead of an + // instance is needed. + comment.value = comment.value.replace(/typeof /g, ''); + + // Convert `import("path/to/module").export` to + // `module:path/to/module~Name` + let importMatch; + while ((importMatch = importRegEx.exec(comment.value))) { + importRegEx.lastIndex = 0; + const rel = path.resolve(path.dirname(currentSourceName), importMatch[2]); + const importModule = path.relative(path.join(process.cwd(), 'src'), rel).replace(/\.js$/, ''); + const exportName = importMatch[3] === 'default' ? getDefaultExportName(importModule, parser) : importMatch[3]; + const replacement = `module:${importModule}${exportName ? '~' + exportName : ''}`; + comment.value = comment.value.replace(importMatch[0], replacement + importMatch[4]); + } + + // Treat `@typedef`s like named exports + const typedefMatch = comment.value.replace(/\r?\n?\s*\*\s/g, ' ').match(typedefRegEx); + if (typedefMatch) { + identifiers[typedefMatch[1]] = { + value: path.basename(currentSourceName) + }; + } + + // Replace local types with the full `module:` path + Object.keys(identifiers).forEach(key => { + const regex = new RegExp(`(@fires |[\{<\|,] ?)${key}`, 'g'); + if (regex.test(comment.value)) { + const identifier = identifiers[key]; + const absolutePath = path.resolve(path.dirname(currentSourceName), identifier.value); + const moduleId = path.relative(path.join(process.cwd(), 'src'), absolutePath).replace(/\.js$/, ''); + const exportName = identifier.defaultImport ? getDefaultExportName(moduleId, parser) : key; + comment.value = comment.value.replace(regex, '$1' + `module:${moduleId}${exportName ? '~' + exportName : ''}`); + } + }); + }); + } + } + } + +}; diff --git a/config/jsdoc/api/plugins/inheritdoc.js b/config/jsdoc/api/plugins/inheritdoc.js new file mode 100755 index 0000000000..e252269bcc --- /dev/null +++ b/config/jsdoc/api/plugins/inheritdoc.js @@ -0,0 +1,109 @@ +/* + * This is a hack to prevent inheritDoc tags from entirely removing + * documentation of the method that inherits the documentation. + * + * TODO: Remove this hack when https://github.com/jsdoc3/jsdoc/issues/53 + * is addressed. + */ + + +exports.defineTags = function(dictionary) { + dictionary.defineTag('inheritDoc', { + mustHaveValue: false, + canHaveType: false, + canHaveName: false, + onTagged: function(doclet, tag) { + doclet.inheritdoc = true; + } + }); +}; + + +const lookup = {}; +const incompleteByClass = {}; +const keepKeys = ['comment', 'meta', 'name', 'memberof', 'longname', 'augment', + 'stability']; + +exports.handlers = { + + newDoclet: function(e) { + const doclet = e.doclet; + let incompletes; + if (!(doclet.longname in lookup)) { + lookup[doclet.longname] = []; + } + lookup[doclet.longname].push(doclet); + if (doclet.inheritdoc) { + if (!(doclet.memberof in incompleteByClass)) { + incompleteByClass[doclet.memberof] = []; + } + incompletes = incompleteByClass[doclet.memberof]; + if (incompletes.indexOf(doclet.name) == -1) { + incompletes.push(doclet.name); + } + } + }, + + parseComplete: function(e) { + let ancestors, candidate, candidates, doclet, i, j, k, l, key; + let incompleteDoclet, stability, incomplete, incompletes; + const doclets = e.doclets; + for (i = doclets.length - 1; i >= 0; --i) { + doclet = doclets[i]; + if (doclet.augments) { + ancestors = [].concat(doclet.augments); + } + incompletes = incompleteByClass[doclet.longname]; + if (ancestors && incompletes) { + // collect ancestors from the whole hierarchy + for (j = 0; j < ancestors.length; ++j) { + candidates = lookup[ancestors[j]]; + if (candidates) { + for (k = candidates.length - 1; k >= 0; --k) { + candidate = candidates[k]; + if (candidate.augments) { + ancestors = ancestors.concat(candidate.augments); + } + } + } + } + // walk through all inheritDoc members + for (j = incompletes.length - 1; j >= 0; --j) { + incomplete = incompletes[j]; + candidates = lookup[doclet.longname + '#' + incomplete]; + if (candidates) { + // get the incomplete doclet that needs to be augmented + for (k = candidates.length - 1; k >= 0; --k) { + incompleteDoclet = candidates[k]; + if (incompleteDoclet.inheritdoc) { + break; + } + } + } + // find the documented ancestor + for (k = ancestors.length - 1; k >= 0; --k) { + candidates = lookup[ancestors[k] + '#' + incomplete]; + if (candidates) { + for (l = candidates.length - 1; l >= 0; --l) { + candidate = candidates[l]; + if (candidate && !candidate.inheritdoc) { + stability = candidate.stability || incompleteDoclet.stability; + if (stability) { + incompleteDoclet.stability = stability; + for (key in candidate) { + if (candidate.hasOwnProperty(key) && + keepKeys.indexOf(key) == -1) { + incompleteDoclet[key] = candidate[key]; + } + } + } + } + } + } + } + } + } + } + } + +}; diff --git a/config/jsdoc/api/plugins/normalize-exports.js b/config/jsdoc/api/plugins/normalize-exports.js deleted file mode 100644 index a24e767cd5..0000000000 --- a/config/jsdoc/api/plugins/normalize-exports.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @filedesc - * Expands module path type to point to default export when no name is given - */ - -const fs = require('fs'); -const path = require('path'); - -let moduleRoot; - -function addDefaultExportPath(obj) { - if (!Array.isArray(obj)) { - obj = obj.names; - } - obj.forEach((name, index) => { - const matches = name.match(/module\:([^>|),\.<]|)+/g); - if (matches) { - matches.forEach(module => { - if (!/[~\.]/.test(module)) { - const checkFile = path.resolve(moduleRoot, module.replace(/^module\:/, '')); - const file = fs.readFileSync(require.resolve(checkFile), 'utf-8'); - const lines = file.split('\n'); - let hasDefaultExport = false; - for (let i = 0, ii = lines.length; i < ii; ++i) { - hasDefaultExport = hasDefaultExport || /^export default [^\{]/.test(lines[i]); - const match = lines[i].match(/^export default ([A-Za-z_$][A-Za-z0-9_$]+);$/); - if (match) { - // Use variable name if default export is assigned to a variable. - obj[index] = name = name.replace(module, `${module}~${match[1]}`); - return; - } - } - if (hasDefaultExport) { - // Duplicate last part if default export is not assigned to a variable. - obj[index] = name = name.replace(module, `${module}~${module.split('/').pop()}`); - } - } - }); - } - }); -} - -function replaceLinks(comment) { - const matches = comment.match(/\{@link [^\} #]+}/g); - if (matches) { - const modules = matches.map(m => { - const mm = m.match(/(module:[^\}]+)}$/); - if (mm) { - return mm[1]; - } - }).filter(m => !!m); - const newModules = modules.concat(); - addDefaultExportPath(newModules); - modules.forEach((module, i) => { - comment = comment.replace(module, newModules[i]); - }); - } - return comment; -} - -exports.handlers = { - - /** - * Adds default export to module path types without name - * @param {Object} e Event object. - */ - newDoclet: function(e) { - const doclet = e.doclet; - if (doclet.kind == 'module') { - const levelsUp = doclet.longname.replace(/^module\:/, '').split('/'); - if (doclet.meta.filename != 'index.js') { - levelsUp.pop(); - } - const pathArgs = [doclet.meta.path].concat(levelsUp.map(() => '../')); - moduleRoot = path.resolve.apply(null, pathArgs); - } else { - if (doclet.description) { - doclet.description = replaceLinks(doclet.description); - } - if (doclet.classdesc) { - doclet.classdesc = replaceLinks(doclet.classdesc); - } - - const module = doclet.longname.split('#').shift(); - if (module.indexOf('module:') == 0 && module.indexOf('.') !== -1) { - doclet.longname = doclet.longname.replace(module, module.replace('.', '~')); - } - if (doclet.augments) { - addDefaultExportPath(doclet.augments); - } - if (doclet.params) { - doclet.params.forEach(p => addDefaultExportPath(p.type)); - } - if (doclet.returns) { - doclet.returns.forEach(r => addDefaultExportPath(r.type)); - } - if (doclet.properties) { - doclet.properties.forEach(p => addDefaultExportPath(p.type)); - } - if (doclet.type) { - addDefaultExportPath(doclet.type); - } - } - } - -}; diff --git a/config/jsdoc/api/plugins/normalize-longnames.js b/config/jsdoc/api/plugins/normalize-longnames.js new file mode 100644 index 0000000000..34bda84717 --- /dev/null +++ b/config/jsdoc/api/plugins/normalize-longnames.js @@ -0,0 +1,21 @@ +/** + * @filedesc + * Normalize module path to make no distinction between static and member at + * the module level. + */ + +exports.handlers = { + + /** + * Adds default export to module path types without name + * @param {Object} e Event object. + */ + newDoclet: function(e) { + const doclet = e.doclet; + const module = doclet.longname.split('#').shift(); + if (module.indexOf('module:') == 0 && module.indexOf('.') !== -1) { + doclet.longname = doclet.longname.replace(module, module.replace('.', '~')); + } + } + +}; diff --git a/config/jsdoc/api/plugins/typedefs.js b/config/jsdoc/api/plugins/typedefs.js deleted file mode 100644 index 66227c3c04..0000000000 --- a/config/jsdoc/api/plugins/typedefs.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Changes @enum annotations into @typedef. - */ - -// types that are undefined or typedefs containing undefined -let undefinedLikes = null; - -/** - * Changes the description of the param if it is required. - * @param {Object} doclet The doclet. - * @returns {Object} The modified doclet. - */ -function markRequiredIfNeeded(doclet) { - const memberof = doclet.memberof; - if (!memberof) { - return doclet; - } - - const types = doclet.type.names; - let isRequiredParam = true; - - // iterate over all types that are like-undefined (see above for explanation) - for (let idx = undefinedLikes.length - 1; idx >= 0; idx--) { - const undefinedLike = undefinedLikes[idx]; - // … if the current types contains a type that is undefined-like, - // it is not required. - if (types.indexOf(undefinedLike) != -1) { - isRequiredParam = false; - } - } - - if (isRequiredParam) { - const reqSnippet = 'Required.

'; - const endsWithP = /<\/p>$/i; - let description = doclet.description; - if (description && endsWithP.test(description)) { - description = description.replace(endsWithP, ' ' + reqSnippet); - } else if (doclet.description === undefined) { - description = '

' + reqSnippet; - } - doclet.description = description; - } - return doclet; -} - -/** - * Iterates over all doclets and finds the names of types that contain - * undefined. Stores the names in the global variable undefinedLikes, so - * that e.g. markRequiredIfNeeded can use these. - * @param {Array} doclets The doclets. - */ -function findTypesLikeUndefined(doclets) { - undefinedLikes = ['undefined']; // include type 'undefined' explicitly - for (let i = doclets.length - 1; i >= 0; --i) { - const doclet = doclets[i]; - if (doclet.kind === 'typedef') { - const types = doclet.type.names; - if (types.indexOf('undefined') !== -1) { - // the typedef contains 'undefined', so it self is undefinedLike. - undefinedLikes.push(doclet.longname); - } - } - } -} - -exports.handlers = { - - newDoclet: function(e) { - const doclet = e.doclet; - if (doclet.isEnum) { - // We never export enums, so we document them like typedefs - doclet.kind = 'typedef'; - delete doclet.isEnum; - } - }, - - parseComplete: function(e) { - const doclets = e.doclets; - findTypesLikeUndefined(doclets); - for (let i = doclets.length - 1; i >= 0; --i) { - markRequiredIfNeeded(doclets[i]); - } - } - -};