diff --git a/packages/jsdoc-core/lib/api.js b/packages/jsdoc-core/lib/api.js index 0939aa28..c888b445 100644 --- a/packages/jsdoc-core/lib/api.js +++ b/packages/jsdoc-core/lib/api.js @@ -20,6 +20,8 @@ import path from 'node:path'; import { Env } from '@jsdoc/core'; import glob from 'fast-glob'; +const DEFAULT_TEMPLATE = '@jsdoc/template-legacy'; + /** * The API for programmatically generating documentation with JSDoc. * @@ -38,10 +40,14 @@ export default class Api { constructor(opts) { /** * An event emitter that acts as a message bus for all JSDoc modules. + * + * @type {node:events.EventEmitter} */ this.emitter = opts?.emitter ?? new EventEmitter(); /** * The JSDoc environment, including configuration settings, for this API instance. + * + * @type {module:@jsdoc/core.Env} */ this.env = opts?.env ?? new Env(); } @@ -97,4 +103,52 @@ export default class Api { return this.env.sourceFiles?.slice() ?? []; } + + /** + * Generates documentation with the template specified in the JSDoc environment, or with + * `@jsdoc/template-legacy` if no template is specified. + * + * The template must export a `publish` function that generates output when called and returns a + * `Promise`. + * + * @param {module:@jsdoc/doclet.DocletStore} docletStore - The doclet store obtained by parsing + * the source files. + * @returns {Promise<*>} A promise that is fulfilled after the template runs. + */ + async generateDocs(docletStore) { + let message; + const { log, options } = this.env; + let template; + + options.template ??= DEFAULT_TEMPLATE; + + try { + template = await import(options.template); + } catch (e) { + if (options.template === DEFAULT_TEMPLATE) { + message = + `Unable to load the default template, \`${DEFAULT_TEMPLATE}\`. You can install ` + + 'the default template, or you can install a different template and configure JSDoc to ' + + 'use that template.'; + } else { + message = `Unable to load the template \`${options.template}\`: ${e.message ?? e}`; + } + + log.fatal(message); + + return Promise.reject(new Error(message)); + } + + // Templates must export a `publish` function. + if (template.publish && typeof template.publish === 'function') { + log.info('Generating output files...'); + + return template.publish(docletStore, this.env); + } else { + message = `\`${options.template}\` does not export a \`publish\` function.`; + log.fatal(message); + + return Promise.reject(new Error(message)); + } + } } diff --git a/packages/jsdoc-core/test/fixtures/templates/bad-publish-template.js b/packages/jsdoc-core/test/fixtures/templates/bad-publish-template.js new file mode 100644 index 00000000..23f1b4bc --- /dev/null +++ b/packages/jsdoc-core/test/fixtures/templates/bad-publish-template.js @@ -0,0 +1,17 @@ +/* + Copyright 2024 the JSDoc Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +export const publish = 'foo'; diff --git a/packages/jsdoc-core/test/fixtures/templates/fake-template.js b/packages/jsdoc-core/test/fixtures/templates/fake-template.js new file mode 100644 index 00000000..7ed3dbca --- /dev/null +++ b/packages/jsdoc-core/test/fixtures/templates/fake-template.js @@ -0,0 +1,22 @@ +/* + Copyright 2024 the JSDoc Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +export function publish(docletStore, env) { + env.docletStore = docletStore; + env.foo = 'bar'; + + return Promise.resolve(); +} diff --git a/packages/jsdoc-core/test/fixtures/templates/no-publish-template.js b/packages/jsdoc-core/test/fixtures/templates/no-publish-template.js new file mode 100644 index 00000000..a18d0e20 --- /dev/null +++ b/packages/jsdoc-core/test/fixtures/templates/no-publish-template.js @@ -0,0 +1,17 @@ +/* + Copyright 2024 the JSDoc Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +export const foo = 'bar'; diff --git a/packages/jsdoc-core/test/specs/lib/api.js b/packages/jsdoc-core/test/specs/lib/api.js index fe00eb78..a0894867 100644 --- a/packages/jsdoc-core/test/specs/lib/api.js +++ b/packages/jsdoc-core/test/specs/lib/api.js @@ -173,4 +173,100 @@ describe('Api', () => { }); }); }); + + describe('generateDocs', () => { + let env; + let options; + + beforeEach(() => { + env = instance.env; + options = env.options; + options.template = path.resolve( + path.join(__dirname, '../../fixtures/templates/fake-template.js') + ); + }); + + it('calls the `publish` function from the specified template', async () => { + await instance.generateDocs(); + + expect(env.foo).toBe('bar'); + }); + + it('passes the `DocletStore` and `Env` to the template', async () => { + const fakeDocletStore = {}; + + await instance.generateDocs(fakeDocletStore); + + expect(env.docletStore).toBe(fakeDocletStore); + }); + + it('uses the legacy template by default', async () => { + options.template = null; + + try { + await instance.generateDocs(); + + // We shouldn't get here. + expect(false).toBeTrue(); + } catch (e) { + expect(e.message).toContain('default template'); + expect(e.message).toContain('@jsdoc/template-legacy'); + } + }); + + it('rejects the promise and logs a fatal error if the template cannot be found', async () => { + spyOn(env.log, 'fatal'); + options.template = 'fleeble'; + + try { + await instance.generateDocs(); + + // We shouldn't get here. + expect(false).toBeTrue(); + } catch (e) { + expect(e.message).toContain('fleeble'); + } + + expect(env.log.fatal).toHaveBeenCalled(); + expect(env.log.fatal.calls.first().args[0]).toContain('fleeble'); + }); + + it('rejects the promise and logs a fatal error if `publish` is undefined', async () => { + spyOn(env.log, 'fatal'); + options.template = path.resolve( + path.join(__dirname, '../../fixtures/templates/no-publish-template.js') + ); + + try { + await instance.generateDocs(); + + // We shouldn't get here. + expect(false).toBeTrue(); + } catch (e) { + expect(e.message).toContain('no-publish-template.js'); + } + + expect(env.log.fatal).toHaveBeenCalled(); + expect(env.log.fatal.calls.first().args[0]).toContain('no-publish-template.js'); + }); + + it('rejects the promise and logs a fatal error if `publish` is not a function', async () => { + spyOn(env.log, 'fatal'); + options.template = path.resolve( + path.join(__dirname, '../../fixtures/templates/bad-publish-template.js') + ); + + try { + await instance.generateDocs(); + + // We shouldn't get here. + expect(false).toBeTrue(); + } catch (e) { + expect(e.message).toContain('bad-publish-template.js'); + } + + expect(env.log.fatal).toHaveBeenCalled(); + expect(env.log.fatal.calls.first().args[0]).toContain('bad-publish-template.js'); + }); + }); }); diff --git a/packages/jsdoc/cli.js b/packages/jsdoc/cli.js index aa338ba3..cac2a0c2 100644 --- a/packages/jsdoc/cli.js +++ b/packages/jsdoc/cli.js @@ -260,36 +260,7 @@ export default (() => { return cli; }; - cli.generateDocs = async () => { - let message; - const { options } = env; - let template; - - options.template ??= '@jsdoc/template-legacy'; - - try { - template = await import(options.template); - } catch (e) { - log.fatal(`Unable to load template: ${e.message ?? e}`); - } - - // templates should export a "publish" function - if (template.publish && typeof template.publish === 'function') { - let publishPromise; - - log.info('Generating output files...'); - publishPromise = template.publish(props.docs, env); - - return Promise.resolve(publishPromise); - } else { - message = - `${options.template} does not export a "publish" function. ` + - 'Global "publish" functions are no longer supported.'; - log.fatal(message); - - return Promise.reject(new Error(message)); - } - }; + cli.generateDocs = () => api.generateDocs(props.docs); return cli; })();