diff --git a/.github/workflows/docs-gh-pages.yml b/.github/workflows/docs-gh-pages.yml index ef4a93c9..255d94d2 100644 --- a/.github/workflows/docs-gh-pages.yml +++ b/.github/workflows/docs-gh-pages.yml @@ -33,6 +33,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Build with Typedoc + run: npm i && npm run docs - name: Setup Ruby uses: ruby/setup-ruby@8575951200e472d5f2d95c625da0c7bec8217c42 # v1.161.0 with: diff --git a/.gitignore b/.gitignore index 6967f2a9..bb4a7a00 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .vscode dist/ +docs/api/ diff --git a/docs/_config.yml b/docs/_config.yml index fa63bf90..6f49846d 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -40,6 +40,8 @@ markdown: kramdown theme: just-the-docs color_scheme: derby-light # just-the-docs theme customization permalink: /:path/:name +keep_files: + - api/ # Front matter defaults defaults: @@ -55,6 +57,14 @@ defaults: values: render_with_liquid: true +nav_external_links: + - title: Derby API + url: /api + opens_in_new_tab: true + - title: Racer API + url: https://derbyjs.github.io/racer + opens_in_new_tab: true + # Exclude from processing. # The following items will not be processed, by default. # Any item listed under the `exclude:` key here will be automatically added to diff --git a/package.json b/package.json index 5febdbfb..2b292961 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "scripts": { "build": "node_modules/.bin/tsc", "checks": "npm run lint && npm test", + "docs": "npx typedoc", "lint": "npx eslint src/**/*.ts test/**/*.js", "lint:ts": "npx eslint src/**/*.ts", "lint:fix": "npm run lint:ts -- --fix", @@ -78,6 +79,9 @@ "prettier": "^3.0.1", "racer": "^v2.0.0-beta.11", "ts-node": "^10.9.2", + "typedoc": "^0.25.13", + "typedoc-plugin-mdn-links": "^3.1.28", + "typedoc-plugin-missing-exports": "^2.2.0", "typescript": "~5.1.3" }, "peerDependencies": { diff --git a/src/Controller.ts b/src/Controller.ts index 5ac22d0c..886dc081 100644 --- a/src/Controller.ts +++ b/src/Controller.ts @@ -11,6 +11,9 @@ export class Controller extends EventEmitter { dom: Dom; app: App; page: Page; + /** + * Model scoped to this instance's "private" data. + */ model: ChildModel; markerNode: Node; diff --git a/src/components.ts b/src/components.ts index 1b4c7970..b2b4fac8 100644 --- a/src/components.ts +++ b/src/components.ts @@ -48,9 +48,18 @@ export interface ComponentViewDefinition { export abstract class Component extends Controller { context: Context; + /** + * Unique ID assigned to the component + */ id: string; + /** + * Whether the component instance is fully destroyed. Initially set to false. + */ isDestroyed: boolean; page: Page; + /** + * Reference to the containing controller + */ parent: Controller; singleton?: true; _scope: string[]; @@ -83,8 +92,26 @@ export abstract class Component extends Controller this.isDestroyed = false; } + /** + * Method called by Derby after instantiating a component and before rendering the template. + * + * This should initialize any data needed by the component, like with `this.model.start(...)`. + * + * `init()` could be called from the server and the browser, so do not use any DOM-only methods + * here. Put those in `create()` instead. + */ init(_model: ChildModel): void {} + /** + * Method called by Derby once a component is loaded and ready in the DOM. + * + * Any model listeners (`this.model.on(...)`) and DOM listeners (`this.dom.addListener(...)`) + * should be added here. + * + * This will only be called in the browser. + */ + create?: (() => void) | (() => Promise); + destroy() { this.emit('destroy'); this.model.removeContextListeners(); @@ -97,42 +124,58 @@ export abstract class Component extends Controller this.isDestroyed = true; } - // Apply calls to the passed in function with the component as the context. - // Stop calling back once the component is destroyed, which avoids possible bugs - // and memory leaks. - bind(callback: (...args: unknown[]) => void) { + /** + * Generate a function, bound function to the component instance's `this`. + * The returned function will no longer be invoked once the component is destroyed. + * + * @param fn - A function to be invoked with the component as its `this` value. + * @returns a bound function, similar to JavaScript's Function.bind() + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_objects/Function/bind + */ + bind(fn: (...args: unknown[]) => void) { // eslint-disable-next-line @typescript-eslint/no-this-alias let _component = this; - let _callback = callback; + let _fn = fn; this.on('destroy', function() { // Reduce potential for memory leaks by removing references to the component // and the passed in callback, which could have closure references _component = null; // Cease calling back after component is removed from the DOM - _callback = null; + _fn = null; }); return function componentBindWrapper(...args) { - if (!_callback) return; - return _callback.apply(_component, ...args); + if (!_fn) return; + return _fn.apply(_component, ...args); }; } - // When passing in a numeric delay, calls the function at most once per that - // many milliseconds. Like Underscore, the function will be called on the - // leading and the trailing edge of the delay as appropriate. Unlike Underscore, - // calls are consistently called via setTimeout and are never synchronous. This - // should be used for reducing the frequency of ongoing updates, such as scroll - // events or other continuous streams of events. - // - // Additionally, implements an interface intended to be used with - // window.requestAnimationFrame or process.nextTick. If one of these is passed, - // it will be used to create a single async call following any number of - // synchronous calls. This mode is typically used to coalesce many synchronous - // events (such as multiple model events) into a single async event. - // - // Like component.bind(), will no longer call back once the component is - // destroyed, which avoids possible bugs and memory leaks. - throttle(callback: (...args: unknown[]) => void, delayArg?: number | ((fn: () => void) => void)) { + /** + * Generate a function that, when passing in a numeric delay, calls the function at + * most once per that many milliseconds. Additionally, implements an interface + * intended to be used with window.requestAnimationFrame, process.nextTick, or window.setImmediate. + * + * @param fn - A function to be invoked with the component instance as its `this` value. + * @param delayArg - Amount of time (in ms) to wait until invoking `fn` again. Default '0'. + * + * When passing in a numeric delay, calls the function at most once per that + * many milliseconds. Like Underscore, the function will be called on the + * leading and the trailing edge of the delay as appropriate. Unlike Underscore, + * calls are consistently called via setTimeout and are never synchronous. This + * should be used for reducing the frequency of ongoing updates, such as scroll + * events or other continuous streams of events. + * + * Additionally, implements an interface intended to be used with + * window.requestAnimationFrame or process.nextTick. If one of these is passed, + * it will be used to create a single async call following any number of + * synchronous calls. This mode is typically used to coalesce many synchronous + * events (such as multiple model events) into a single async event. + * Like component.bind(), will no longer call back once the component is +- * destroyed, which avoids possible bugs and memory leaks. + * + * @returns a bound function + */ + throttle(fn: (...args: unknown[]) => void, delayArg?: number | ((fn: () => void) => void)) { // eslint-disable-next-line @typescript-eslint/no-this-alias let _component = this; this.on('destroy', function() { @@ -140,7 +183,7 @@ export abstract class Component extends Controller // and the passed in callback, which could have closure references _component = null; // Cease calling back after component is removed from the DOM - callback = null; + fn = null; }); // throttle(callback) @@ -153,8 +196,8 @@ export abstract class Component extends Controller const args = nextArgs; nextArgs = null; previous = +new Date(); - if (callback && args) { - callback.apply(_component, args); + if (fn && args) { + fn.apply(_component, args); } }; return function componentThrottleWrapper(...args) { @@ -175,8 +218,8 @@ export abstract class Component extends Controller const boundCallback = function() { const args = nextArgs; nextArgs = null; - if (callback && args) { - callback.apply(_component, args); + if (fn && args) { + fn.apply(_component, args); } }; return function componentThrottleWrapper(...args) { @@ -189,30 +232,36 @@ export abstract class Component extends Controller throw new Error('Second argument must be a delay function or number'); } - // Checks that component is not destroyed before calling callback function - // which avoids possible bugs and memory leaks. - requestAnimationFrame(callback: () => void) { - const safeCallback = _safeWrap(this, callback); + /** + * Safe wrapper around `window.requestAnimationFrame` that ensures function not invoked + * when component has been destroyed + * @param fn - A function to be invoked with the component instance as its `this` value. + */ + requestAnimationFrame(fn: () => void) { + const safeCallback = _safeWrap(this, fn); window.requestAnimationFrame(safeCallback); } - // Checks that component is not destroyed before calling callback function - // which avoids possible bugs and memory leaks. - nextTick(callback: () => void) { - const safeCallback = _safeWrap(this, callback); + /** + * Safe wrapper around `process.nextTick` that ensures function not invoked + * when component has been destroyed + * @param fn - A function to be invoked with the component instance as its `this` value. + */ + nextTick(fn: () => void) { + const safeCallback = _safeWrap(this, fn); process.nextTick(safeCallback); } - // Suppresses calls until the function is no longer called for that many - // milliseconds. This should be used for delaying updates triggered by user - // input, such as window resizing, or typing text that has a live preview or - // client-side validation. This should not be used for inputs that trigger - // server requests, such as search autocomplete; use debounceAsync for those - // cases instead. - // - // Like component.bind(), will no longer call back once the component is - // destroyed, which avoids possible bugs and memory leaks. - debounce(callback: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { + /** + * Suppresses calls until the function is no longer called for that many milliseconds. + * This should be used for delaying updates triggered by user input or typing text. + * + * @param fn - A function to be invoked with the component instance as its `this` value. + * @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'. + * + * @returns a bound function + */ + debounce(fn: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { delay = delay || 0; if (typeof delay !== 'number') { throw new Error('Second argument must be a number'); @@ -224,7 +273,7 @@ export abstract class Component extends Controller // and the passed in callback, which could have closure references component = null; // Cease calling back after component is removed from the DOM - callback = null; + fn = null; }); let nextArgs; let timeout; @@ -232,8 +281,8 @@ export abstract class Component extends Controller const args = nextArgs; nextArgs = null; timeout = null; - if (callback && args) { - callback.apply(component, args); + if (fn && args) { + fn.apply(component, args); } }; return function componentDebounceWrapper(...args: Parameters) { @@ -243,24 +292,31 @@ export abstract class Component extends Controller }; } - // Forked from: https://github.com/juliangruber/async-debounce - // - // Like debounce(), suppresses calls until the function is no longer called for - // that many milliseconds. In addition, suppresses calls while the callback - // function is running. In other words, the callback will not be called again - // until the supplied done() argument is called. When the debounced function is - // called while the callback is running, the callback will be called again - // immediately after done() is called. Thus, the callback will always receive - // the last value passed to the debounced function. - // - // This avoids the potential for multiple callbacks to execute in parallel and - // complete out of order. It also acts as an adaptive rate limiter. Use this - // method to debounce any field that triggers an async call as the user types. - // - // Like component.bind(), will no longer call back once the component is - // destroyed, which avoids possible bugs and memory leaks. - debounceAsync(callback: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { - const applyArguments = callback.length !== 1; + /** + * Like debounce(), suppresses calls until the function is no longer called for + * that many milliseconds. In addition, suppresses calls while the callback + * function is running. In other words, the callback will not be called again + * until the supplied done() argument is called. When the debounced function is + * called while the callback is running, the callback will be called again + * immediately after done() is called. Thus, the callback will always receive + * the last value passed to the debounced function. + * + * This avoids the potential for multiple callbacks to execute in parallel and + * complete out of order. It also acts as an adaptive rate limiter. Use this + * method to debounce any field that triggers an async call as the user types. + * + * Like component.bind(), will no longer call back once the component is + * destroyed, which avoids possible bugs and memory leaks. + * + * Forked from: https://github.com/juliangruber/async-debounce + * + * @param fn - A function to be invoked with the component instance as its `this` value. + * @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'. + * + * @returns a bound function + */ + debounceAsync(fn: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { + const applyArguments = fn.length !== 1; delay = delay || 0; if (typeof delay !== 'number') { throw new Error('Second argument must be a number'); @@ -272,7 +328,7 @@ export abstract class Component extends Controller // and the passed in callback, which could have closure references component = null; // Cease calling back after component is removed from the DOM - callback = null; + fn = null; }); let running = false; let nextArgs; @@ -281,10 +337,10 @@ export abstract class Component extends Controller const args = nextArgs; nextArgs = null; timeout = null; - if (callback && args) { + if (fn && args) { running = true; args.push(done); - callback.apply(component, args); + fn.apply(component, args); } else { running = false; } @@ -313,10 +369,19 @@ export abstract class Component extends Controller this.app.views.find(viewName, contextView.namespace) : contextView; } - getAttribute(key: string) { - const attributeContext = this.context.forAttribute(key); + /** + * Retrieve the appropriate view attribute's value for a given view instance. + * If the value is a template, it will be rendered prior to being returned. + * + * @param attrName the name of the view attribute used with a component instance + * @returns any of the possible values that can be expressed with a view attribute + * + * @see https://derbyjs.github.io/derby/views/template-syntax/view-attributes + */ + getAttribute(attrName: string) { + const attributeContext = this.context.forAttribute(attrName); if (!attributeContext) return; - let value = attributeContext.attributes[key]; + let value = attributeContext.attributes[attrName]; if (value instanceof expressions.Expression) { value = value.get(attributeContext); } @@ -536,7 +601,7 @@ const _extendComponent = (Object.setPrototypeOf && Object.getPrototypeOf) ? // Find the end of the prototype chain const rootPrototype = getRootPrototype(constructor.prototype); - // This guard is a workaroud to a bug that has occurred in Chakra when + // This guard is a workaround to a bug that has occurred in Chakra when // app.component() is invoked twice on the same constructor. In that case, // the `instanceof Component` check in extendComponent incorrectly returns // false after the prototype has already been set to `Component.prototype`. diff --git a/src/routes.ts b/src/routes.ts index 8a6b5344..e7777aa9 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -10,16 +10,25 @@ export function routes(app: App) { // From tracks/lib/router.js export interface PageParams extends ReadonlyArray { - /** Previous URL path + querystring */ + /** + * Previous URL path + querystring + */ previous?: string; - /** Current URL path + querystring */ + + /** + * Current URL path + querystring + */ url: string; + /** * Parsed query parameters * @see https://www.npmjs.com/package/qs */ query: Readonly; - /** HTTP method for the currently rendered page */ + + /** + * HTTP method for the currently rendered page + */ method: string; routes: unknown; } diff --git a/src/templates/contexts.ts b/src/templates/contexts.ts index 0737f10f..00e632f6 100644 --- a/src/templates/contexts.ts +++ b/src/templates/contexts.ts @@ -8,6 +8,9 @@ import { Controller } from '../Controller'; function noop() { } +/** + * Properties and methods which are globally inherited for the entire page + */ export class ContextMeta { addBinding: (binding: any) => void = noop; removeBinding: (binding: any) => void = noop; @@ -82,6 +85,11 @@ export class Context { this._eventModels = null; } + /** + * Generate unique Id + * + * @returns namespaced Id + */ id() { const count = ++this.meta.idCount; return this.meta.idNamespace + '_' + count.toString(36); @@ -136,7 +144,13 @@ export class Context { return new Context(this.meta, component, this, this.unbound); } - // Make a context for an item in an each block + /** + * Make a context for an item in an each block + * + * @param expression + * @param item + * @returns new Context + */ eachChild(expression, item) { const context = new Context(this.meta, this.controller, this, this.unbound, expression); context.item = item; @@ -210,6 +224,11 @@ export class Context { } } + /** + * Gets the current `context` view or closest `context.parent` view + * + * @returns view + */ getView() { // eslint-disable-next-line @typescript-eslint/no-this-alias let context: Context = this; diff --git a/src/test-utils/ComponentHarness.ts b/src/test-utils/ComponentHarness.ts index 2861203f..2e450e44 100644 --- a/src/test-utils/ComponentHarness.ts +++ b/src/test-utils/ComponentHarness.ts @@ -80,7 +80,12 @@ export class ComponentHarness extends EventEmitter { document: Document; model: RootModel; page: PageForHarness; - + + /** + * Creates a `ComponentHarness`. + * + * If arguments are provided, then `#setup` is called with the arguments. + */ constructor() { super(); this.app = new AppForHarness(this); @@ -94,15 +99,15 @@ export class ComponentHarness extends EventEmitter { /** @typedef { {view: {is: string, source?: string}} } InlineComponent */ /** - * Sets up the harness with a HTML template, which should contain a `` for the - * component under test, and the components to register for the test. - * - * @param {string} source - HTML template for the harness page - * @param {...(Component | InlineComponent} components - components to register for the test - * - * @example - * var harness = new ComponentHarness().setup('', Dialog); - */ + * Sets up the harness with a HTML template, which should contain a `` for the + * component under test, and the components to register for the test. + * + * @param {string} source - HTML template for the harness page + * @param {...(Component | InlineComponent} components - components to register for the test + * + * @example + * var harness = new ComponentHarness().setup('', Dialog); + */ setup(source: string, ...components: ComponentConstructor[]) { this.app.views.register('$harness', source); // Remaining variable arguments are components diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..61081c4c --- /dev/null +++ b/typedoc.json @@ -0,0 +1,17 @@ +{ + "entryPoints": ["src/index.ts"], + "excludeNotDocumented": false, + "plugin":[ + "typedoc-plugin-mdn-links", + "typedoc-plugin-missing-exports", + "./typedocExcludeUnderscore.mjs" + ], + "out": "docs/_site/api", + "visibilityFilters": { + "protected": false, + "private": false, + "inherited": true, + "external": false + }, + "excludePrivate": true + } \ No newline at end of file diff --git a/typedocExcludeUnderscore.mjs b/typedocExcludeUnderscore.mjs new file mode 100644 index 00000000..22d1b708 --- /dev/null +++ b/typedocExcludeUnderscore.mjs @@ -0,0 +1,30 @@ +import { Converter, ReflectionFlag, ReflectionKind } from "typedoc"; +import camelCase from "camelcase"; + +/** + * @param {Readonly} app + */ +export function load(app) { + /** + * Create declaration event handler that sets symbols with underscore-prefixed names + * to private to exclude from generated documentation. + * + * Due to "partial class" style of code in use, otherwise private properties and methods - + * prefixed with underscore - are effectively declared public so they can be accessed in other + * files used to build class - e.g. Model. This marks anything prefixed with an underscore and + * no doc comment as private. + * + * @param {import('typedoc').Context} context + * @param {import('typedoc').DeclarationReflection} reflection + */ + function handleCreateDeclaration(context, reflection) { + if (!reflection.name.startsWith('_')) { + return; + } + if (!reflection.comment) { + reflection.setFlag(ReflectionFlag.Private); + } + } + + app.converter.on(Converter.EVENT_CREATE_DECLARATION, handleCreateDeclaration); +} \ No newline at end of file