Files
2026-09-23 11:00:23 +01:00

379 lines
14 KiB
TypeScript

import { createElement, isValidElement, type ReactNode } from "react";
import { pluginRegistry } from "@/lib/plugins/registry";
import { isPluginIconComponent, resolvePluginIcon } from "@/lib/plugins/icons";
import type { PluginTaskMenuActionRegistration } from "@/lib/plugins/registry-registration-types";
import type {
PluginIcon,
PluginTaskMenuContext,
TaskMenuSubItemRegistration,
} from "@/lib/plugins/types";
import type { KanbanCardMenuEntry } from "../kanban-card-menu-items";
/**
* Registered actions for `group`, filtered by `visible(context)` (default
* visible when omitted). A `visible` that throws is caught, logged, and
* treated as hidden — the same defensive handling as `run`'s rejection.
*/
export function visiblePluginMenuActions(
group: PluginTaskMenuActionRegistration["group"],
context: PluginTaskMenuContext,
): PluginTaskMenuActionRegistration[] {
return pluginRegistry.getTaskMenuActions(group).filter((action) => {
if (!action.visible) return true;
try {
return action.visible(context);
} catch (error: unknown) {
console.error(
`[plugins] task menu action "${describeAction(action)}" visible() threw`,
error,
);
return false;
}
});
}
/**
* Plugin icons get the same sizing every neighbouring native menu icon uses.
* A ready-made element — the shape registered before icon names/components
* were resolved, and still shipped by kandev-plugin-tags — renders as-is:
* passing it to the resolver would treat it as a name, miss the curated map,
* and silently replace the plugin's own glyph with the fallback puzzle.
*/
function pluginMenuIcon(icon?: PluginIcon): ReactNode {
if (!icon) return undefined;
try {
if (isValidElement(icon)) return icon;
return createElement(resolvePluginIcon(icon), { className: "mr-2 h-4 w-4" });
} catch {
// `isValidElement` reads `$$typeof` again, so an icon whose accessors answer
// once and then throw must mean "no icon" rather than a failed render.
return undefined;
}
}
/**
* Plugin bundles are plain JavaScript, so the registration types are a
* promise, not a guarantee. One log per action *and kind* is enough: `items()`
* runs on every menu build, so a permanently malformed registration would
* otherwise log on every render, while a later defect of a different kind on
* the same action still reports. The set is never cleared, so the same kind
* after re-registration stays silent for the life of the page.
* The registry guards its own read of each registration before this module
* receives a copy. The guards below handle malformed values in that copy.
*/
const loggedMenuDefects = new Set<string>();
/**
* A plugin value's printable form. Template interpolation would call `ToString`,
* which throws for a value that has no string form -- a `Symbol` id or a
* null-prototype object -- and the omission paths are exactly where such a value
* arrives, so the log must not be able to throw.
*/
function printable(value: unknown): string {
if (typeof value === "string") return value;
try {
return String(value);
} catch {
// i18n-exempt: log placeholder for a value with no string form, never rendered.
return "<unprintable>";
}
}
/** The action's identity as a log label, read defensively for the same reason. */
function describeAction(action: PluginTaskMenuActionRegistration): string {
try {
return `${printable(action.pluginId)}:${printable(action.id)}`;
} catch {
// i18n-exempt: log placeholder for a registration that cannot be read, never rendered.
return "<unreadable registration>";
}
}
function logMenuDefect(
action: PluginTaskMenuActionRegistration,
kind: string,
detail?: unknown,
): void {
const actionLabel = describeAction(action);
const key = `${actionLabel}:${kind}`;
if (loggedMenuDefects.has(key)) return;
loggedMenuDefects.add(key);
console.error(`[plugins] task menu action "${actionLabel}" ${kind}`, detail);
}
/**
* The action's own label, read once and only when it is a non-blank string.
* A registration is plugin-authored data too, so a missing or hostile accessor
* omits the entry (and reports it) rather than handing React an object child.
*/
/**
* Escapes the three characters a menu key gives meaning to -- `%` first, so an
* escape already inside an id cannot alias one this adds, then `:` and `#` -- and
* leaves every other character verbatim.
*
* That is what makes the key injective for any plugin-authored string: every `%`
* in the result starts one of exactly three two-character escapes, so decoding is
* unambiguous, and neither the part separator (`:`) nor the child separator (`#`)
* survives raw inside a part. A `%XX`-style per-code-unit escape cannot do this,
* because a bare `%` separator is then indistinguishable from the start of an
* escape: `p` + `abcd\uABCD` and `p\uABCD` + `abcd` would share one key.
*
* It also cannot throw, which is why it replaced `encodeURIComponent` here: that
* raises `URIError` on a lone surrogate, and an exception on this path escapes a
* card's render, where no error boundary catches it.
*/
function encodeKeyPart(value: string): string {
return value.replace(/%/g, "%25").replace(/:/g, "%3A").replace(/#/g, "%23");
}
/** The action's own id, read once and only when it is a non-blank string. */
function readActionId(action: PluginTaskMenuActionRegistration): string | null {
try {
const { id } = action;
return typeof id === "string" && id.trim().length > 0 ? id : null;
} catch {
return null;
}
}
function readActionLabel(action: PluginTaskMenuActionRegistration): string | null {
try {
const { label } = action;
return typeof label === "string" && label.trim().length > 0 ? label : null;
} catch {
return null;
}
}
/** The action's own icon, read once; an unreadable one simply means no icon. */
function readActionIcon(action: PluginTaskMenuActionRegistration): ReactNode {
try {
return pluginMenuIcon(action.icon);
} catch {
logMenuDefect(action, "registration icon could not be read");
return undefined;
}
}
/**
* An icon the menu can render: a curated name, a plugin-owned component
* (including React's exotic components, which are objects at run time), or the
* menu-only ready-made element. Everything else is a defect, while an absent
* icon is null or undefined in a plain bundle.
*/
function isRenderableIcon(icon: unknown): boolean {
if (icon === undefined || icon === null) return true;
return typeof icon === "string" || isPluginIconComponent(icon) || isValidElement(icon);
}
/** One child, read once into the plain values the menu actually renders. */
type SubItemSnapshot = {
id: string;
label: string;
icon?: PluginIcon;
disabled?: boolean;
run: (context: PluginTaskMenuContext) => void | Promise<void>;
};
/**
* Reads one plugin-supplied child into a snapshot the render can trust: a
* non-blank id and label, a callable run, and optional fields of the shapes
* the entry builder understands (a curated name, a component, or a ready-made
* element -- never an arbitrary object, which the icon resolver would coerce
* into a key). Everything the render will read is read here, inside the
* catch, so a throwing getter or a Proxy fails this child instead of the
* card's render, and nothing is read twice.
*/
function readSubItem(item: unknown): SubItemSnapshot | null {
if (!item || typeof item !== "object") return null;
try {
const { id, label, icon, disabled, run } = item as Partial<TaskMenuSubItemRegistration>;
if (typeof id !== "string" || id.trim().length === 0) return null;
if (typeof label !== "string" || label.trim().length === 0) return null;
if (typeof run !== "function") return null;
if (disabled !== undefined && disabled !== null && typeof disabled !== "boolean") return null;
if (!isRenderableIcon(icon)) return null;
return { id, label, icon: icon ?? undefined, disabled: disabled ?? undefined, run };
} catch {
return null;
}
}
/**
* The children of an action that declares `items`, or null when the action
* has no usable submenu — leaving `run` as the entry's behavior.
*
* That fallback is the whole defensive boundary, because `items()` is a
* plugin callback running inside the host's menu build, and every way its
* result can be unreadable is the same answer: a throw, a thenable (the
* contract is synchronous), a non-array, a result whose entries cannot be
* rendered (blank/absent id or label, no callable run, an icon of the wrong
* shape), a child that cannot even be read (a throwing getter, a Proxy), and
* a repeated id all degrade to the flat item — or drop just that child —
* instead of crashing the card's render, producing a trigger nothing can
* open, or handing React children it cannot key. The body is one `try` on
* purpose: a plugin object is free to throw from any property access,
* including the array methods and the `then` lookup this function would
* otherwise perform on it.
*/
function pluginSubItems(
action: PluginTaskMenuActionRegistration,
context: PluginTaskMenuContext,
): readonly SubItemSnapshot[] | null {
try {
if (typeof action.items !== "function") return null;
const result: unknown = action.items(context);
if (result && typeof (result as { then?: unknown }).then === "function") {
logMenuDefect(action, "items() returned a promise; it must be synchronous");
// Observe the rejection so an out-of-contract async callback cannot
// surface as an unhandled rejection out of the host's menu build.
void Promise.resolve(result).catch((error: unknown) => {
logMenuDefect(action, "items() rejected", error);
});
return null;
}
if (!Array.isArray(result)) {
// null/undefined means "no children"; anything else is out of contract.
if (result !== null && result !== undefined) {
logMenuDefect(action, "items() must return an array");
}
return null;
}
// Array.from copies through the array's own length and indices, so no
// array method is looked up on the plugin's object.
const list = Array.from(result as readonly unknown[]);
const seen = new Set<string>();
const usable: SubItemSnapshot[] = [];
list.forEach((item) => {
const child = readSubItem(item);
if (!child) {
logMenuDefect(action, "items() returned an unusable child", item);
return;
}
if (seen.has(child.id)) {
logMenuDefect(action, "items() repeated a child id", child.id);
return;
}
seen.add(child.id);
usable.push(child);
});
return usable.length > 0 ? usable : null;
} catch (error: unknown) {
logMenuDefect(action, "items() could not be read", error);
return null;
}
}
/**
* `Promise.resolve().then(() => run(context))` — not
* `Promise.resolve(run(context))` — so a *synchronous* throw inside run()
* also lands in the `.catch()` below. Calling run directly as the
* Promise.resolve() argument still throws before that expression finishes
* evaluating, escaping past .catch() entirely and straight out of this
* onSelect handler. `subject` names the failing part of a submenu action.
*/
function runPluginCallback(
action: PluginTaskMenuActionRegistration,
run: (context: PluginTaskMenuContext) => void | Promise<void>,
context: PluginTaskMenuContext,
subject = "",
): void {
Promise.resolve()
.then(() => run(context))
.catch((error: unknown) => {
console.error(
`[plugins] task menu action "${action.pluginId}:${action.id}"${subject} failed`,
error,
);
});
}
/**
* Builds the menu entry for one plugin task menu action: a submenu when the
* action declares a non-empty `items(context)`, otherwise the flat item it
* has always been (and still is on a host that predates submenus, which
* ignores `items` and reads only `label`/`icon`/`run`).
*/
export function pluginMenuEntry(
action: PluginTaskMenuActionRegistration,
context: PluginTaskMenuContext,
disabled?: boolean,
keyPrefix = "plugin-edit",
): KanbanCardMenuEntry | null {
const label = readActionLabel(action);
if (label === null) {
logMenuDefect(action, "registration has no usable label");
return null;
}
const actionId = readActionId(action);
if (actionId === null) {
logMenuDefect(action, "registration has no usable id");
return null;
}
const icon = readActionIcon(action);
// Every plugin-controlled part is percent-encoded, so a dash-join cannot spell
// another action's key (`p` + `q-x` vs `p-q` + `x`) and no part can contain the
// separators: the palette flattens every plugin entry into one list where the
// key is both a React key and cmdk's value.
const key = `${keyPrefix}-${encodeKeyPart(action.pluginId)}:${encodeKeyPart(actionId)}`;
const items = pluginSubItems(action, context);
if (items) {
return {
kind: "submenu",
key,
icon,
label,
disabled,
children: items.map((item) => ({
kind: "item",
// The id is plugin-controlled and the action key is itself a dash-join
// of plugin-controlled ids, so `-${id}` can spell another action's child
// key exactly; the palette flattens every plugin entry into one list and
// uses this key as both its React key and cmdk's value. The separator
// stays unambiguous because an id's own `#` is percent-encoded.
key: `${key}#${encodeKeyPart(item.id)}`,
icon: pluginMenuIcon(item.icon),
label: item.label,
disabled: disabled || item.disabled,
onSelect: () => runPluginCallback(action, item.run, context, ` item "${item.id}"`),
})),
};
}
if (typeof action.run !== "function") {
logMenuDefect(action, "registration has no run");
return null;
}
return {
kind: "item",
key,
icon,
label,
disabled,
onSelect: () => runPluginCallback(action, action.run, context),
};
}
/**
* Builds flat, top-level menu entries for group "primary" task menu
* actions, in registration order. Unlike group "edit" (nested in the Edit
* submenu), these render directly in the card menu — after the movement
* group and before the Archive/Delete removal group. An action that declares
* `items` is the one exception: it renders as its own submenu.
*/
export function buildPrimaryPluginEntries({
disabled,
context,
}: {
disabled?: boolean;
context: PluginTaskMenuContext;
}): KanbanCardMenuEntry[] {
return visiblePluginMenuActions("primary", context)
.map((action) => pluginMenuEntry(action, context, disabled, "plugin-primary"))
.filter((entry): entry is KanbanCardMenuEntry => entry !== null);
}