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();