diff --git a/packages/jsdoc-doclet/lib/augment.js b/packages/jsdoc-doclet/lib/augment.js index b2f3e9d9..d8f0a8db 100644 --- a/packages/jsdoc-doclet/lib/augment.js +++ b/packages/jsdoc-doclet/lib/augment.js @@ -17,9 +17,8 @@ * Provides methods for augmenting the parse results based on their content. */ import { name } from '@jsdoc/core'; -import _ from 'lodash'; -import { combineDoclets } from './doclet.js'; +import { combineDoclets, Doclet } from './doclet.js'; const { fromParts, SCOPE, toParts } = name; @@ -37,7 +36,7 @@ function mapDependencies(index, propertyName) { if (kinds.includes(doc.kind)) { dependencies[indexName] = {}; if (Object.hasOwn(doc, propertyName)) { - len = doc[propertyName].length; + len = doc[propertyName]?.length; for (let j = 0; j < len; j++) { dependencies[indexName][doc[propertyName][j]] = true; } @@ -382,7 +381,8 @@ function getMixedInAdditions(mixinDoclets, allDoclets, { documented, memberof }) continue; } - mixedDoclet = _.cloneDeep(mixedDoclets[k]); + mixedDoclet = new Doclet('', null, mixedDoclets[k].dependencies); + mixedDoclet = combineDoclets(mixedDoclets[k], mixedDoclet); updateMixes(mixedDoclet, mixedDoclet.longname); mixedDoclet.mixed = true; @@ -411,9 +411,7 @@ function updateImplements(implDoclets, implementedLongname) { } implDoclets.forEach((implDoclet) => { - if (!Object.hasOwn(implDoclet, 'implements')) { - implDoclet.implements = []; - } + implDoclet.implements ??= []; if (!implDoclet.implements.includes(implementedLongname)) { implDoclet.implements.push(implementedLongname); diff --git a/packages/jsdoc-doclet/lib/borrow.js b/packages/jsdoc-doclet/lib/borrow.js index d33696f1..da79b56e 100644 --- a/packages/jsdoc-doclet/lib/borrow.js +++ b/packages/jsdoc-doclet/lib/borrow.js @@ -22,7 +22,7 @@ import _ from 'lodash'; const { SCOPE } = name; function cloneBorrowedDoclets({ borrowed, longname }, doclets) { - borrowed.forEach(({ from, as }) => { + borrowed?.forEach(({ from, as }) => { const borrowedDoclets = doclets.index.longname[from]; let borrowedAs = as || from; let parts; @@ -60,7 +60,7 @@ function cloneBorrowedDoclets({ borrowed, longname }, doclets) { export function resolveBorrows(doclets) { for (let doclet of doclets.index.borrowed) { cloneBorrowedDoclets(doclet, doclets); - delete doclet.borrowed; + doclet.borrowed = undefined; } doclets.index.borrowed = []; diff --git a/packages/jsdoc-doclet/lib/doclet.js b/packages/jsdoc-doclet/lib/doclet.js index 1cc7eca6..563730ee 100644 --- a/packages/jsdoc-doclet/lib/doclet.js +++ b/packages/jsdoc-doclet/lib/doclet.js @@ -40,6 +40,21 @@ const DEFAULT_SCOPE = SCOPE.NAMES.STATIC; // TODO: `class` should be on this list, right? What are the implications of adding it? const GLOBAL_KINDS = ['constant', 'function', 'member', 'typedef']; +export const WATCHABLE_PROPS = [ + 'access', + 'augments', + 'borrowed', + 'ignore', + 'implements', + 'kind', + 'listens', + 'longname', + 'memberof', + 'mixes', + 'scope', + 'undocumented', +]; + function fakeMeta(node) { return { type: node ? node.type : null, @@ -242,7 +257,7 @@ function resolve(doclet) { if (doclet.scope === SCOPE.NAMES.GLOBAL) { // via @global tag? doclet.setLongname(doclet.name); - delete doclet.memberof; + doclet.memberof = undefined; } else if (about.scope) { if (about.memberof === LONGNAMES.GLOBAL) { // via @memberof ? @@ -375,6 +390,7 @@ function copySpecificProperties(primary, secondary, target, include) { export class Doclet { #accessConfig; #dictionary; + #eventBus; /** * Create a doclet. @@ -389,12 +405,31 @@ export class Doclet { meta = meta || {}; this.#accessConfig = dependencies.get('config')?.opts?.access ?? []; this.#dictionary = dependencies.get('tags'); - /** The original text of the comment from the source code. */ - this.comment = docletSrc; + this.#eventBus = dependencies.get('eventBus'); + Object.defineProperty(this, 'dependencies', { enumerable: false, value: dependencies, }); + Object.defineProperty(this, 'watchableProps', { + enumerable: false, + value: {}, + writable: true, + }); + for (const prop of WATCHABLE_PROPS) { + Object.defineProperty(this, prop, { + enumerable: true, + get() { + return this.watchableProps[prop]; + }, + set(newValue) { + this.#setWatchableProperty(prop, newValue); + }, + }); + } + + /** The original text of the comment from the source code. */ + this.comment = docletSrc; this.setMeta(meta); docletSrc = unwrap(docletSrc); @@ -409,6 +444,15 @@ export class Doclet { this.postProcess(); } + #setWatchableProperty(name, newValue) { + const oldValue = this.watchableProps[name]; + + if (newValue !== oldValue) { + this.watchableProps[name] = newValue; + this.#eventBus.emit('docletChanged', { doclet: this, property: name, oldValue, newValue }); + } + } + // TODO: We call this method in the constructor _and_ in `jsdoc/src/handlers`. It appears that // if we don't call the method twice, various doclet properties can be incorrect, including name // and memberof. @@ -421,7 +465,7 @@ export class Doclet { this.setLongname(this.name); } if (this.memberof === '') { - delete this.memberof; + this.memberof = undefined; } if (!this.kind && this.meta && this.meta.code) { diff --git a/packages/jsdoc-doclet/test/specs/lib/doclet.js b/packages/jsdoc-doclet/test/specs/lib/doclet.js index 222b5b71..c5a10f84 100644 --- a/packages/jsdoc-doclet/test/specs/lib/doclet.js +++ b/packages/jsdoc-doclet/test/specs/lib/doclet.js @@ -38,6 +38,10 @@ describe('@jsdoc/doclet/lib/doclet', () => { expect(doclet.Doclet).toBeFunction(); }); + it('has a WATCHABLE_PROPS array', () => { + expect(doclet.WATCHABLE_PROPS).toBeArrayOfStrings(); + }); + describe('combineDoclets', () => { it('overrides most properties of the secondary doclet', () => { const primaryDoclet = new Doclet( @@ -347,6 +351,7 @@ describe('@jsdoc/doclet/lib/doclet', () => { config.opts.access = access.slice(); } map.set('config', config); + map.set('eventBus', jsdoc.deps.get('eventBus')); map.set('tags', jsdoc.deps.get('tags')); return map; @@ -385,7 +390,7 @@ describe('@jsdoc/doclet/lib/doclet', () => { const newDoclet = makeDoclet(['@name foo', '@function']); // Just to be sure. - delete newDoclet.access; + newDoclet.access = undefined; expect(newDoclet.isVisible()).toBeTrue(); }); @@ -402,7 +407,7 @@ describe('@jsdoc/doclet/lib/doclet', () => { newDoclet = makeDoclet(tags, fakeDeps); // Just to be sure. if (!value) { - delete newDoclet.access; + newDoclet.access = undefined; } return newDoclet; @@ -439,7 +444,7 @@ describe('@jsdoc/doclet/lib/doclet', () => { const newDoclet = makeDoclet(['@function', '@name foo'], fakeDeps); // Just to be sure. - delete newDoclet.access; + newDoclet.access = undefined; expect(newDoclet.isVisible()).toBeFalse(); }); @@ -488,6 +493,76 @@ describe('@jsdoc/doclet/lib/doclet', () => { expect(setScope).toThrow(); }); }); + + describe('watchable properties', () => { + const eventBus = jsdoc.deps.get('eventBus'); + let events; + + function listener(e) { + events.push(e); + } + + beforeEach(() => { + eventBus.on('docletChanged', listener); + events = []; + }); + + afterEach(() => { + eventBus.removeListener('docletChanged', listener); + }); + + it('sends events to the event bus when watchable properties change', () => { + const propValues = { + access: 'private', + augments: 'Foo', + borrowed: true, + ignore: true, + implements: 'Foo', + kind: 'class', + listens: 'event:foo', + longname: 'foo', + memberof: 'foo', + mixes: 'foo', + scope: 'static', + undocumented: true, + }; + const keys = Object.keys(propValues); + + // Make sure this test covers all watchable properties. + expect(keys).toEqual(doclet.WATCHABLE_PROPS); + + keys.forEach((key) => { + const newDoclet = new Doclet('/** Huzzah, a doclet! */', null, jsdoc.deps); + + events = []; + + // Generates first event. + newDoclet[key] = propValues[key]; + // Generates second event. + newDoclet[key] = undefined; + + expect(events.length).toBe(2); + + expect(events[0]).toBeObject(); + expect(events[0].doclet).toBe(newDoclet); + expect(events[0].property).toBe(key); + if (key === 'kind') { + expect(events[0].oldValue).toBe('member'); + } else if (key === 'longname') { + expect(events[0].oldValue).toBeEmptyString(); + } else { + expect(events[0].oldValue).toBeUndefined(); + } + expect(events[0].newValue).toBe(propValues[key]); + + expect(events[1]).toBeObject(); + expect(events[1].doclet).toBe(newDoclet); + expect(events[1].property).toBe(key); + expect(events[1].oldValue).toBe(propValues[key]); + expect(events[1].newValue).toBeUndefined(); + }); + }); + }); }); }); }); diff --git a/packages/jsdoc-parse/lib/parser.js b/packages/jsdoc-parse/lib/parser.js index 4c7bd6d2..e1d5b6dc 100644 --- a/packages/jsdoc-parse/lib/parser.js +++ b/packages/jsdoc-parse/lib/parser.js @@ -631,7 +631,7 @@ export class Parser extends EventEmitter { e.doclet.type = _.cloneDeep(doclet.type); } - delete e.doclet.undocumented; + e.doclet.undocumented = undefined; e.doclet.defaultvalue = e.doclet.meta.code.value; // add the doclet to the parent's properties diff --git a/packages/jsdoc-tag/lib/definitions/core.js b/packages/jsdoc-tag/lib/definitions/core.js index 85d97fce..1094189b 100644 --- a/packages/jsdoc-tag/lib/definitions/core.js +++ b/packages/jsdoc-tag/lib/definitions/core.js @@ -44,7 +44,7 @@ export const tags = { if (/^(package|private|protected|public)$/i.test(value)) { doclet.access = value.toLowerCase(); } else { - delete doclet.access; + doclet.access = undefined; } }, }, @@ -263,7 +263,7 @@ export const tags = { mustNotHaveValue: true, onTagged(doclet) { doclet.scope = SCOPE.NAMES.GLOBAL; - delete doclet.memberof; + doclet.memberof = undefined; }, }, hideconstructor: { @@ -349,7 +349,7 @@ export const tags = { doclet.forceMemberof = true; if (tag.value === LONGNAMES.GLOBAL) { doclet.addTag('global'); - delete doclet.memberof; + doclet.memberof = undefined; } } util.setDocletMemberof(doclet, tag); diff --git a/packages/jsdoc/cli.js b/packages/jsdoc/cli.js index d40f7caa..319dd1a2 100644 --- a/packages/jsdoc/cli.js +++ b/packages/jsdoc/cli.js @@ -179,6 +179,8 @@ export default (() => { let cmd; const options = dependencies.get('options'); + dependencies.registerValue('eventBus', bus); + // If we already need to exit with an error, don't do any more work. if (props.shouldExitWithError) { cmd = () => Promise.resolve(0);