mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
feat(plugins): accept inline SVG and component icons
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.
The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginHostProps, PluginIcon } from "./contracts.js";
|
||||
|
||||
export type PluginButtonContext =
|
||||
| { context: "workspace"; workspaceId: string }
|
||||
| { context: "agent"; workspaceId: string; agentId: string };
|
||||
|
||||
export type PluginButtonIconProps = PluginHostProps &
|
||||
PluginButtonContext & { size: number; color: string };
|
||||
export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps;
|
||||
|
||||
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
|
||||
|
||||
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
|
||||
export type PluginButtonIcon = PluginIcon<PluginButtonIconProps>;
|
||||
|
||||
export type PluginButtonBehavior =
|
||||
| { kind: "action"; onPress(): void | Promise<void> }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PluginTimelineTransformResult,
|
||||
PluginCleanup,
|
||||
} from "../contracts.js";
|
||||
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: PluginTheme;
|
||||
@@ -87,11 +88,13 @@ export interface PluginSidebarItemContribution {
|
||||
Component: ComponentType<PluginSidebarItemProps>;
|
||||
}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
/**
|
||||
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
||||
* and color; buttons widen the props with their host and target context.
|
||||
*/
|
||||
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
|
||||
| PluginSerializableIcon
|
||||
| ComponentType<Props>;
|
||||
|
||||
export type PluginPanelLocation = "workspace" | "explorer";
|
||||
|
||||
@@ -102,7 +105,7 @@ export interface PluginOpenPanelOptions {
|
||||
interface PluginWorkspacePanelBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
locations?: readonly PluginPanelLocation[];
|
||||
}
|
||||
|
||||
@@ -168,7 +171,7 @@ export type PluginWorkspacePanelContribution =
|
||||
export interface PluginSettingsScreenContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ export interface PluginSurfaceContribution {
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
@@ -259,7 +262,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
||||
interface PluginCommandCenterItemBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type {
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
PluginSidebarItemContribution,
|
||||
PluginIconProps,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
PluginWorkspacePanelProps,
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginHeaderButtonContribution,
|
||||
} from "./buttons.js";
|
||||
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
@@ -53,12 +54,16 @@ export interface ToastApi {
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
|
||||
export type IconProps = Partial<PluginIconProps> &
|
||||
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
|
||||
|
||||
export declare const Icon: ComponentType<IconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
|
||||
|
||||
export type { PluginIconProps } from "./contracts.js";
|
||||
export type { PluginIconProps } from "../icons.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
title: string;
|
||||
@@ -64,14 +65,14 @@ export interface ExternalLinkProps {
|
||||
}
|
||||
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
|
||||
|
||||
export type SidebarIcon = string | ComponentType<{ size: number; color: string }>;
|
||||
export type SidebarIcon = PluginIcon;
|
||||
export interface SidebarRowProps {
|
||||
/**
|
||||
* Tells rows of one item apart when the item renders several: "bot-2". Unique within the item.
|
||||
* Omit it when the item renders one row.
|
||||
*/
|
||||
id?: string;
|
||||
/** A Lucide icon name or a component. */
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
/** Defaults to the item's registered title. */
|
||||
label?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginSerializableIcon } from "./icons.js";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export interface PluginTheme {
|
||||
@@ -73,7 +74,7 @@ export interface PluginThemeContribution {
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginSerializableIcon;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSvgIconProblem } from "./icons.js";
|
||||
|
||||
describe("findSvgIconProblem", () => {
|
||||
it.each([
|
||||
["a plain SVG", '<svg viewBox="0 0 24 24"><path d="M0 0h24v24z" /></svg>'],
|
||||
["leading whitespace", '\n <svg viewBox="0 0 24 24"></svg>'],
|
||||
[
|
||||
"a fragment reference",
|
||||
'<svg><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>',
|
||||
],
|
||||
["currentColor fills", '<svg fill="currentColor"><circle r="4" /></svg>'],
|
||||
])("accepts %s", (_name, svg) => {
|
||||
expect(findSvgIconProblem(svg)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-SVG document", "hello", "not an SVG document"],
|
||||
["an <svgx> element", "<svgx></svgx>", "not an SVG document"],
|
||||
["a script", "<svg><script /></svg>", "script elements are not allowed"],
|
||||
["a foreign object", "<svg><foreignObject /></svg>", "foreignObject elements are not allowed"],
|
||||
["a style", "<svg><style /></svg>", "style elements are not allowed"],
|
||||
[
|
||||
"an event handler",
|
||||
'<svg><path onclick="run()" /></svg>',
|
||||
"event-handler attributes are not allowed",
|
||||
],
|
||||
[
|
||||
"a javascript URL",
|
||||
'<svg><a href="javascript:run()" /></svg>',
|
||||
"javascript URLs are not allowed",
|
||||
],
|
||||
[
|
||||
"an external href",
|
||||
'<svg><use href="https://example.com/icon.svg" /></svg>',
|
||||
"external href references are not allowed",
|
||||
],
|
||||
[
|
||||
"an external xlink:href",
|
||||
"<svg><image xlink:href=icon.png /></svg>",
|
||||
"external href references are not allowed",
|
||||
],
|
||||
["markup over 64 KiB", `<svg>${" ".repeat(64 * 1024)}</svg>`, "exceeds 64 KiB"],
|
||||
])("rejects %s", (_name, svg, reason) => {
|
||||
expect(findSvgIconProblem(svg)).toBe(reason);
|
||||
});
|
||||
|
||||
it("measures the size in UTF-8 bytes", () => {
|
||||
const multibyte = "é".repeat(32 * 1024);
|
||||
|
||||
expect(findSvgIconProblem(`<svg>${multibyte}</svg>`)).toBe("exceeds 64 KiB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/** What a component icon receives from the surface that draws it. */
|
||||
export interface PluginIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Inline SVG markup. Paseo validates it when the contribution registers. */
|
||||
export interface PluginSvgIcon {
|
||||
svg: string;
|
||||
}
|
||||
|
||||
/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */
|
||||
export type PluginSerializableIcon = string | PluginSvgIcon;
|
||||
|
||||
export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
|
||||
/**
|
||||
* Why Paseo refuses to render this SVG, or null when it is a self-contained document.
|
||||
* The daemon applies it to provider and usage-source icon files; the app to inline plugin icons.
|
||||
*/
|
||||
export function findSvgIconProblem(svg: string): string | null {
|
||||
if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) {
|
||||
return "exceeds 64 KiB";
|
||||
}
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) return "not an SVG document";
|
||||
if (/<script(?:\s|>)/i.test(svg)) return "script elements are not allowed";
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) return "foreignObject elements are not allowed";
|
||||
if (/<style(?:\s|>)/i.test(svg)) return "style elements are not allowed";
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed";
|
||||
if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed";
|
||||
for (const match of svg.matchAll(EXTERNAL_HREF)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) return "external href references are not allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -20,3 +20,10 @@ export {
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
||||
export {
|
||||
findSvgIconProblem,
|
||||
PLUGIN_SVG_ICON_MAX_BYTES,
|
||||
type PluginIconProps,
|
||||
type PluginSerializableIcon,
|
||||
type PluginSvgIcon,
|
||||
} from "./icons.js";
|
||||
|
||||
Reference in New Issue
Block a user