jsdoc-api
Version:
A programmatic interface for jsdoc
428 lines (376 loc) • 12.9 kB
JavaScript
'use strict';
var Cache = require('cache-point');
var arrayify = require('array-back');
var path = require('path');
var fs = require('node:fs');
var os = require('os');
var crypto = require('crypto');
var fg = require('fast-glob');
var fs$1 = require('fs');
var assert = require('assert');
var walkBack = require('walk-back');
var currentModulePaths = require('current-module-paths');
var toSpawnArgs = require('object-to-spawn-args');
var cp = require('child_process');
var util = require('node:util');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
class TempFile {
constructor (source) {
this.path = path.join(TempFile.tempFileDir, crypto.randomBytes(6).toString('hex') + '.js');
fs.writeFileSync(this.path, source);
}
delete () {
try {
fs.unlinkSync(this.path);
} catch (err) {
// already deleted
}
}
static tempFileDir = path.join(os.homedir(), '.jsdoc-api/temp')
static cacheDir = path.join(os.homedir(), '.jsdoc-api/cache')
static createTmpDirs () {
/* No longer using os.tmpdir(). See: https://github.com/jsdoc2md/jsdoc-api/issues/19 */
fs.mkdirSync(TempFile.tempFileDir, { recursive: true });
fs.mkdirSync(TempFile.cacheDir, { recursive: true });
}
}
class FileSet {
constructor () {
/* validation */
if (arguments.length) {
throw new Error('new Fileset() does not require any arguments')
}
/** • fileSet.files :string[]
≈ The existing files found.
*/
this.files = [];
/** • fileSet.dirs :string[]
≈ The existing directories found. Directory paths will always end with `'/'`.
*/
this.dirs = [];
/** • fileSet.notExisting :string[]
≈ Paths which were not found.
*/
this.notExisting = [];
}
/** ø fileSet.add(patterns)
≈ Add file patterns to the set.
• [patterns] :string|string[] - One or more file paths or glob expressions to inspect.
*/
async add (files, options = {}) {
files = arrayify(files);
for (let file of files) {
/* Force all incoming file paths and glob expressions to use posix separators */
file = os.platform() === 'win32'
? file.replace(/\\/g, path.posix.sep)
: file;
try {
const stat = await fs$1.promises.stat(file);
if (stat.isFile() && !this.files.includes(file)) {
this.files.push(file);
} else if (stat.isDirectory() && !this.dirs.includes(file)) {
this.dirs.push(file.endsWith(path.posix.sep) ? file : `${file}${path.posix.sep}`);
}
} catch (err) {
if (err.code === 'ENOENT') {
if (fg.isDynamicPattern(file)) {
const found = await fg.glob(file, { onlyFiles: false, markDirectories: true });
if (found.length) {
if (options.globResultSortFn) {
found.sort(options.globResultSortFn);
}
for (const match of found) {
if (match.endsWith(path.posix.sep)) {
if (!this.dirs.includes(match)) this.dirs.push(match);
} else {
if (!this.files.includes(match)) this.files.push(match);
}
}
} else {
if (!this.notExisting.includes(file)) this.notExisting.push(file);
}
} else {
if (!this.notExisting.includes(file)) this.notExisting.push(file);
}
} else {
throw err
}
}
}
}
clear () {
this.files = [];
this.dirs = [];
this.notExisting = [];
}
}
const { __dirname: __dirname$1 } = currentModulePaths((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
class JsdocCommand {
constructor (options = {}, cache) {
options.files = arrayify(options.files);
options.source = arrayify(options.source);
assert.ok(
options.files.length || options.source.length || options.configure,
'Must set at least one of .files, .source or .configure'
);
this.cache = cache;
this.tempFiles = [];
const jsdocOptions = Object.assign({}, options);
delete jsdocOptions.files;
delete jsdocOptions.source;
delete jsdocOptions.cache;
/* see: https://github.com/jsdoc2md/jsdoc-api/issues/22 */
if (!jsdocOptions.pedantic) {
delete jsdocOptions.pedantic;
}
this.options = options;
this.jsdocOptions = jsdocOptions;
this.jsdocPath = process.env.JSDOC_PATH || walkBack(
path.join(__dirname$1, '..'),
path.join('node_modules', 'jsdoc', 'jsdoc.js')
);
}
async execute () {
this.inputFileSet = new FileSet();
/* node-glob v9+ (used by file-set) no longer sorts the output by default. We will continue to sort the file list, for now, as it's what the user expected by default. The user's system locale is used. */
const collator = new Intl.Collator();
await this.inputFileSet.add(this.options.files, { globResultSortFn: collator.compare });
if (this.options.source.length) {
this.tempFiles = this.options.source.map(source => new TempFile(source));
this.tempFileSet = new FileSet();
await this.tempFileSet.add(this.tempFiles.map(t => t.path));
}
let result;
try {
result = await this.getOutput();
} finally {
/* run even if getOutput fails */
if (this.tempFiles) {
for (const tempFile of this.tempFiles) {
tempFile.delete();
}
}
}
return result
}
}
util.promisify(cp.exec);
class Explain extends JsdocCommand {
async getOutput () {
if (this.options.cache && !this.options.source.length) {
try {
return await this.readCache()
} catch (err) {
if (err.code === 'ENOENT') {
return this._runJsdoc()
} else {
throw err
}
}
} else {
return this._runJsdoc()
}
}
async _runJsdoc () {
const jsdocArgs = [
this.jsdocPath,
...toSpawnArgs(this.jsdocOptions),
'-X',
...(this.options.source.length ? this.tempFileSet.files : this.inputFileSet.files)
];
let jsdocOutput = { stdout: '', stderr: '' };
const code = await new Promise((resolve, reject) => {
const handle = cp.spawn('node', jsdocArgs);
handle.stdout.setEncoding('utf8');
handle.stderr.setEncoding('utf8');
handle.stdout.on('data', chunk => {
jsdocOutput.stdout += chunk;
});
handle.stderr.on('data', chunk => {
jsdocOutput.stderr += chunk;
});
handle.on('exit', (code) => {
resolve(code);
});
handle.on('error', reject);
});
try {
if (code > 0) {
throw new Error('jsdoc exited with non-zero code: ' + code)
} else {
const explainOutput = JSON.parse(jsdocOutput.stdout);
if (this.options.cache) {
await this.cache.write(this.cacheKey, explainOutput);
}
return explainOutput
}
} catch (err) {
const firstLineOfStdout = jsdocOutput.stdout.split(/\r?\n/)[0];
const jsdocErr = new Error(jsdocOutput.stderr.trim() || firstLineOfStdout || 'Jsdoc failed.');
jsdocErr.name = 'JSDOC_ERROR';
jsdocErr.cause = err;
throw jsdocErr
}
}
async readCache () {
if (this.cache) {
/* Create the cache key then check the cache for a match, returning pre-generated output if so.
The current cache key is a union of the input file names plus their content - this could be expensive when processing a lot of files.
*/
const promises = this.inputFileSet.files.map(file => {
return fs.promises.readFile(file, 'utf8')
});
const contents = await Promise.all(promises);
this.cacheKey = contents.concat(this.inputFileSet.files);
return this.cache.read(this.cacheKey)
} else {
return Promise.reject()
}
}
}
class Render extends JsdocCommand {
async getOutput () {
return new Promise((resolve, reject) => {
const jsdocArgs = toSpawnArgs(this.jsdocOptions)
.concat(this.options.source.length ? this.tempFiles.map(t => t.path) : this.options.files);
jsdocArgs.unshift(this.jsdocPath);
const handle = cp.spawn('node', jsdocArgs, { stdio: 'inherit' });
handle.on('close', resolve);
})
}
}
/**
* @module jsdoc-api
* @typicalname jsdoc
*/
TempFile.createTmpDirs();
/**
* @external cache-point
* @see https://github.com/75lb/cache-point
*/
/**
* The [cache-point](https://github.com/75lb/cache-point) instance used when `cache: true` is specified on `.explain()`.
* @type {external:cache-point}
*/
const cache = new Cache({ dir: TempFile.cacheDir });
/**
* @alias module:jsdoc-api
*/
const jsdoc = {
cache,
/**
* Returns a promise for the jsdoc explain output.
*
* @param [options] {module:jsdoc-api~JsdocOptions}
* @fulfil {object[]} - jsdoc explain output
* @returns {Promise}
*/
async explain (options) {
options = new JsdocOptions(options);
const command = new Explain(options, cache);
return command.execute()
},
/**
* Render jsdoc documentation.
*
* @param [options] {module:jsdoc-api~JsdocOptions}
* @prerequisite Requires node v0.12 or above
* @example
* await jsdoc.render({ files: 'lib/*', destination: 'api-docs' })
*/
async render (options) {
options = new JsdocOptions(options);
const command = new Render(options);
return command.execute()
}
};
/**
* The jsdoc options, common for all operations.
* @typicalname options
*/
class JsdocOptions {
constructor (options = {}) {
/**
* One or more filenames to process. Either `files`, `source` or `configure` must be supplied.
* @type {string|string[]}
*/
this.files = arrayify(options.files);
/**
* A string or an array of strings containing source code to process. Either `files`, `source` or `configure` must be supplied.
* @type {string|string[]}
*/
this.source = options.source;
/**
* Set to `true` to cache the output - future invocations with the same input will return immediately.
* @type {boolean}
* @default
*/
this.cache = options.cache;
/**
* Only display symbols with the given access: "public", "protected", "private" or "undefined", or "all" for all access levels. Default: all except "private".
* @type {string}
*/
this.access = options.access;
/**
* The path to the configuration file. Default: path/to/jsdoc/conf.json. Either `files`, `source` or `configure` must be supplied.
* @type {string}
*/
this.configure = options.configure;
/**
* The path to the output folder. Use "console" to dump data to the console. Default: ./out/.
* @type {string}
*/
this.destination = options.destination;
/**
* Assume this encoding when reading all source files. Default: utf8.
* @type {string}
*/
this.encoding = options.encoding;
/**
* Display symbols marked with the @private tag. Equivalent to "--access all". Default: false.
* @type {boolean}
*/
this.private = options.private;
/**
* The path to the project's package file. Default: path/to/sourcefiles/package.json
* @type {string}
*/
this.package = options.package;
/**
* Treat errors as fatal errors, and treat warnings as errors. Default: false.
* @type {boolean}
*/
this.pedantic = options.pedantic;
/**
* A query string to parse and store in jsdoc.env.opts.query. Example: foo=bar&baz=true.
* @type {string}
*/
this.query = options.query;
/**
* Recurse into subdirectories when scanning for source files and tutorials.
* @type {boolean}
*/
this.recurse = options.recurse;
/**
* The path to the project's README file. Default: path/to/sourcefiles/README.md.
* @type {string}
*/
this.readme = options.readme;
/* Warning to avoid a common mistake where dmd templates are passed in.. a jsdoc template must be a filename. */
if (typeof options.template === 'string' && options.template.split(/\r?\n/).length !== 1) {
console.warn('Suspicious `options.template` value - the jsdoc `template` option must be a file path.');
console.warn(options.template);
}
/**
* The path to the template to use. Default: path/to/jsdoc/templates/default.
* @type {string}
*/
this.template = options.template;
/**
* Directory in which JSDoc should search for tutorials.
* @type {string}
*/
this.tutorials = options.tutorials;
}
}
module.exports = jsdoc;