diff --git a/.husky/pre-commit b/.husky/pre-commit index 41a735df..f61a016a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,13 @@ npm run format && npm run test:unit +# Typecheck the plugin SDK +TYPECHECK_INPUTS='^impro-plugin/(main\.js|tsconfig\.json)' +if git diff --cached --name-only --diff-filter=ACMR | grep -qE "$TYPECHECK_INPUTS"; then + npm run typecheck --prefix impro-plugin +fi + # Rebuild plugin docs if source changed -DOC_INPUTS='^impro-plugin/(main\.js|typedoc\.json|tsconfig\.typedoc\.json|typedoc-support/|docs/)' +DOC_INPUTS='^impro-plugin/(main\.js|typedoc\.json|tsconfig\.json|tsconfig\.typedoc\.json|typedoc-support/|docs/)' if git diff --cached --name-only --diff-filter=ACMR | grep -qE "$DOC_INPUTS"; then npm run docs --prefix impro-plugin git add impro-plugin/docs diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index b0aa51b4..4508b05e 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -14,7 +14,7 @@ The plugin's handle to the running impro app. Exposed as `this.app` on a | Property | Type | Description | | ------ | ------ | ------ | -| `currentUser` | [`ProfileView`](#profileview) | The signed-in user's basic profile, populated before `onload()` runs. Null when no session is active. | +| `currentUser` | [`ProfileView`](#profileview) \| `null` | The signed-in user's basic profile, populated before `onload()` runs. Null when no session is active. | | `data` | [`PluginData`](#plugindata) | Read-only appview accessors — see [PluginData](#plugindata). | #### Methods @@ -90,7 +90,7 @@ to one feed, or omit/pass `null` to refresh every feed. | Parameter | Type | Default value | | ------ | ------ | ------ | -| `feedURI?` | `string` | `null` | +| `feedURI?` | `string` \| `null` | `null` | ###### Returns @@ -627,7 +627,7 @@ an `onClick` handler. Not constructed directly — obtained via | Property | Type | | ------ | ------ | -| `icon` | `string` \| [`VirtualEl`](#virtualel) | +| `icon` | `string` \| [`VirtualEl`](#virtualel) \| `null` | | `title` | `string` | #### Methods @@ -1005,16 +1005,17 @@ nothing else. > **registerPage**(`options`): `void` Registers a full-page view reachable via [Plugin.openPage](#openpage). `display()` -is called on navigation and must return a [VirtualEl](#virtualel) or `null`. +is called on navigation and must return a [VirtualEl](#virtualel), or nothing to +render an empty page. ###### Parameters | Parameter | Type | | ------ | ------ | -| `options` | \{ `display?`: () => [`VirtualEl`](#virtualel) \| `Promise`\<[`VirtualEl`](#virtualel)\>; `id`: `string`; `title?`: `string`; \} | -| `options.display?` | () => [`VirtualEl`](#virtualel) \| `Promise`\<[`VirtualEl`](#virtualel)\> | +| `options` | \{ `display?`: () => [`RenderResult`](#renderresult) \| `Promise`\<[`RenderResult`](#renderresult)\>; `id`: `string`; `title?`: `string` \| `null`; \} | +| `options.display?` | () => [`RenderResult`](#renderresult) \| `Promise`\<[`RenderResult`](#renderresult)\> | | `options.id` | `string` | -| `options.title?` | `string` | +| `options.title?` | `string` \| `null` | ###### Returns @@ -1070,7 +1071,7 @@ The host batches all pending contexts of a render into one call. | Parameter | Type | | ------ | ------ | | `name` | `string` | -| `callback` | (`context`) => [`VirtualEl`](#virtualel) \| `Promise`\<[`VirtualEl`](#virtualel)\> | +| `callback` | (`context`) => [`RenderResult`](#renderresult) \| `Promise`\<[`RenderResult`](#renderresult)\> | | `options?` | \{ `cacheKey?`: `string`[]; \} | | `options.cacheKey?` | `string`[] | @@ -1288,7 +1289,7 @@ to render into `this.containerEl`. Register with `plugin.addSettingTab(tab)`. | Property | Type | Description | | ------ | ------ | ------ | | `containerEl` | [`VirtualEl`](#virtualel) | - | -| `name` | `string` | - | +| `name` | `string` \| `null` | - | | `plugin` | [`Plugin`](#plugin) | The owning plugin. Set by the host in [Plugin.addSettingTab](#addsettingtab). | #### Methods @@ -1700,7 +1701,7 @@ Set the current value. | Parameter | Type | | ------ | ------ | -| `value` | `string` | +| `value` | `string` \| `null` | ###### Returns @@ -1762,7 +1763,7 @@ Set the current value. | Parameter | Type | | ------ | ------ | -| `value` | `string` | +| `value` | `string` \| `null` | ###### Returns @@ -2110,7 +2111,7 @@ Set an attribute; `undefined` coerces to `""`. | Parameter | Type | | ------ | ------ | | `name` | `string` | -| `value` | `string` | +| `value` | `string` \| `undefined` | ###### Returns @@ -2127,7 +2128,7 @@ Set an inline style. | Parameter | Type | | ------ | ------ | | `name` | `string` | -| `value` | `string` | +| `value` | `string` \| `null` | ###### Returns @@ -2143,7 +2144,7 @@ Replace all children with a single [VirtualText](#virtualtext) node. | Parameter | Type | | ------ | ------ | -| `text` | `string` | +| `text` | `string` \| `null` | ###### Returns @@ -2165,7 +2166,7 @@ A text node in a [VirtualEl](#virtualel) tree. Null/undefined coerce to `""`. | Parameter | Type | | ------ | ------ | -| `value` | `string` | +| `value` | `string` \| `null` \| `undefined` | ###### Returns @@ -2244,6 +2245,19 @@ Basic `app.bsky.actor.defs#profileView` shape. *** +### RenderResult + +> **RenderResult** = [`VirtualEl`](#virtualel) \| `null` \| `undefined` + +What a render callback may return: a tree to render, or nothing. + +#### Type Parameters + +| Type Parameter | +| ------ | + +*** + ### RepoRecord > **RepoRecord** = `Record`\<`string`, `unknown`\> diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 571192e3..923d135c 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -13,6 +13,8 @@ * A `app.bsky.feed.defs#feedViewPost` (post + reply/repost context). * @typedef {Record} RichTextToken * One token in a rich-text stream — `text`, `facet`, `inline`, or `block`. + * @typedef {VirtualEl | null | undefined} RenderResult + * What a render callback may return: a tree to render, or nothing. */ export class SimpleUUID { @@ -172,6 +174,7 @@ export class Menu { */ export class Composer { #ops = []; + /** @type {number | null} */ #cursor = null; /** * Replace the composer's current text. @@ -544,6 +547,7 @@ let registered = false; * data. Call `MyPlugin.register()` at the top of your plugin's main.js to boot. */ export class Plugin { + /** @type {PluginSettingTab | null} */ #settingTab = null; /** @internal */ constructor() { @@ -639,7 +643,7 @@ export class Plugin { * @param {(feedUri: string, feedItems: FeedItem[]) => Record | Promise>} callback * @returns {void} */ - addFeedFilter(callback = () => {}) { + addFeedFilter(callback = () => ({})) { const handlerId = uuid.create(); callHandlers.set(handlerId, callback); self.postMessage({ @@ -704,7 +708,7 @@ export class Plugin { * * The host batches all pending contexts of a render into one call. * @param {string} name - * @param {(context: Record) => VirtualEl | null | Promise} callback + * @param {(context: Record) => RenderResult | Promise} callback * @param {{ cacheKey?: string[] }} [options] * @returns {void} */ @@ -738,19 +742,19 @@ export class Plugin { /** * Registers a full-page view reachable via {@link Plugin.openPage}. `display()` - * is called on navigation and must return a {@link VirtualEl} or `null`. - * @param {{ id: string, title?: string | null, display?: () => VirtualEl | null | Promise }} options + * is called on navigation and must return a {@link VirtualEl}, or nothing to + * render an empty page. + * @param {{ id: string, title?: string | null, display?: () => RenderResult | Promise }} options * @returns {void} */ registerPage({ id, title = null, display = () => null }) { const displayHandlerId = uuid.create(); callHandlers.set(displayHandlerId, async () => { - const result = await display(); + const result = /** @type {unknown} */ (await display()); if (result == null) return null; if (!(result instanceof VirtualEl)) { - const description = result?.constructor?.name ?? typeof result; throw new Error( - `Page "${id}" must return a VirtualEl or null, got ${description}`, + `Page "${id}" must return a VirtualEl or null, got ${describeValue(result)}`, ); } return result._serialize(); @@ -839,13 +843,23 @@ export class Plugin { } } +/** + * Names the type of an arbitrary value, for use in an error message. + * @param {unknown} value + * @returns {string} + */ +function describeValue(value) { + if (value === null) return "null"; + if (typeof value !== "object") return typeof value; + return value.constructor?.name ?? "object"; +} + async function getSlotContent(name, callback, context) { - const result = await callback(context); + const result = /** @type {unknown} */ (await callback(context)); if (result == null) return null; if (!(result instanceof VirtualEl)) { - const description = result?.constructor?.name ?? typeof result; throw new Error( - `Slot "${name}" must return a VirtualEl or null, got ${description}`, + `Slot "${name}" must return a VirtualEl or null, got ${describeValue(result)}`, ); } return result._serialize(); @@ -1027,16 +1041,16 @@ export class Modal { * to render into `this.containerEl`. Register with `plugin.addSettingTab(tab)`. */ export class PluginSettingTab { + /** + * The owning plugin. Set by the host in {@link Plugin.addSettingTab}. + * @type {Plugin} + */ + plugin; constructor() { /** @type {VirtualEl} */ this.containerEl = new VirtualEl("div"); /** @type {string | null} */ this.name = null; - /** - * The owning plugin. Set by the host in {@link Plugin.addSettingTab}. - * @type {Plugin} - */ - this.plugin; } /** * Set the tab's label. @@ -1189,7 +1203,7 @@ export class TextComponent { * @returns {this} */ onChange(callback) { - this.el.onChange((event) => callback(event.target.value)); + this.el.onChange((event) => callback(event.target.value ?? "")); return this; } } @@ -1227,7 +1241,7 @@ export class TextAreaComponent { * @returns {this} */ onChange(callback) { - this.el.onChange((event) => callback(event.target.value)); + this.el.onChange((event) => callback(event.target.value ?? "")); return this; } } @@ -1257,7 +1271,7 @@ export class ToggleComponent { * @returns {this} */ onChange(callback) { - this.el.onChange((event) => callback(event.target.checked)); + this.el.onChange((event) => callback(event.target.checked ?? false)); return this; } } @@ -1299,9 +1313,10 @@ export class DropdownComponent { */ setValue(value) { for (const child of this.el.children) { - if (child.attrs?.value === value) { + if (!(child instanceof VirtualEl)) continue; + if (child.attrs.value === value) { child.attrs.selected = ""; - } else if (child.attrs) { + } else { delete child.attrs.selected; } } @@ -1313,7 +1328,7 @@ export class DropdownComponent { * @returns {this} */ onChange(callback) { - this.el.onChange((event) => callback(event.target.value)); + this.el.onChange((event) => callback(event.target.value ?? "")); return this; } } diff --git a/impro-plugin/package.json b/impro-plugin/package.json index 23d84d47..a688e38e 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,10 +1,11 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.20", + "version": "0.0.21", "type": "module", "main": "main.js", "scripts": { - "docs": "typedoc --options typedoc.json" + "docs": "typedoc --options typedoc.json", + "typecheck": "tsc -p tsconfig.json" }, "license": "0BSD", "exports": { diff --git a/impro-plugin/tsconfig.json b/impro-plugin/tsconfig.json new file mode 100644 index 00000000..fafc699f --- /dev/null +++ b/impro-plugin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "strictNullChecks": true, + "noEmit": true + }, + "include": ["main.js"] +} diff --git a/impro-plugin/tsconfig.typedoc.json b/impro-plugin/tsconfig.typedoc.json index b50d6bff..b0f8b21b 100644 --- a/impro-plugin/tsconfig.typedoc.json +++ b/impro-plugin/tsconfig.typedoc.json @@ -1,11 +1,6 @@ { + "extends": "./tsconfig.json", "compilerOptions": { - "allowJs": true, - "checkJs": false, - "module": "esnext", - "target": "esnext", - "moduleResolution": "bundler", - "noEmit": true - }, - "include": ["main.js"] + "checkJs": false + } } diff --git a/tests/unit/specs/plugins/pluginWorker.test.js b/tests/unit/specs/plugins/pluginWorker.test.js index 600446e9..464adab1 100644 --- a/tests/unit/specs/plugins/pluginWorker.test.js +++ b/tests/unit/specs/plugins/pluginWorker.test.js @@ -466,6 +466,38 @@ describe("Plugin sidebar/feedFilter registration", () => { assert(result.error.includes("must return a VirtualEl")); }); + it("registerPage returns null when display returns undefined", async () => { + clearMessages(); + const plugin = new Plugin(); + plugin.registerPage({ id: "dashboard", display: () => undefined }); + const register = lastMessage(); + clearMessages(); + await dispatch({ + type: "call", + handlerId: register.displayHandlerId, + callId: 10, + args: [], + }); + const result = postedMessages.find((message) => message.type === "result"); + assert.deepEqual(result.value, null); + }); + + it("registerPage names the returned type in the error", async () => { + clearMessages(); + const plugin = new Plugin(); + plugin.registerPage({ id: "dashboard", display: () => () => null }); + const register = lastMessage(); + clearMessages(); + await dispatch({ + type: "call", + handlerId: register.displayHandlerId, + callId: 11, + args: [], + }); + const result = postedMessages.find((message) => message.type === "result"); + assert(result.error.includes("got function")); + }); + it("openPage forwards the page id to the host", () => { clearMessages(); const plugin = new Plugin();