mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +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:
@@ -534,6 +534,25 @@ export type Value = string;`,
|
||||
expect(clientBundle).not.toContain("React.createElement");
|
||||
});
|
||||
|
||||
it("imports an SVG file from a runtime directory as its markup", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
await writeFile(path.join(entries.directory, "client", "icon.svg"), svg);
|
||||
await writeFile(
|
||||
entries.client,
|
||||
`import icon from "./client/icon.svg";
|
||||
export default function contribute(client) {
|
||||
client.addCommandCenterItem({ id: "open", title: "Open", icon: { svg: icon }, context: "global", onSelect() {} });
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
const { clientBundle } = await compilePlugin(entries);
|
||||
const registered: unknown[] = [];
|
||||
const exports = new Function(`return ${clientBundle}`)()(() => undefined);
|
||||
exports.default({ addCommandCenterItem: (item: unknown) => registered.push(item) });
|
||||
expect(registered).toMatchObject([{ icon: { svg } }]);
|
||||
});
|
||||
|
||||
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
|
||||
@@ -393,6 +393,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
|
||||
loader: { ".svg": "text" },
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// The neutral platform reads no package.json entry fields, so packages without
|
||||
|
||||
@@ -29,18 +29,14 @@ describe("readPluginProviderIcon", () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
["non-SVG document", "hello", "file is not an SVG document"],
|
||||
["script", "<svg><script /></svg>", "script elements"],
|
||||
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
|
||||
["style", "<svg><style /></svg>", "style elements"],
|
||||
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
|
||||
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
|
||||
["non-SVG document", "hello", "not an SVG document"],
|
||||
["script", "<svg><script /></svg>", "script elements are not allowed"],
|
||||
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "external href"],
|
||||
])("rejects %s", async (_name, svg, reason) => {
|
||||
])("rejects %s with the shared SVG rules", async (_name, svg, reason) => {
|
||||
const directory = await writeIcon(svg);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
`icon.svg": ${reason}`,
|
||||
`Invalid plugin provider icon "icon.svg": ${reason}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,7 +74,7 @@ describe("readPluginProviderIcon", () => {
|
||||
const directory = await writeIcon(`<svg>${" ".repeat(64 * 1024)}</svg>`);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
"file exceeds 64 KiB",
|
||||
`Invalid plugin provider icon "icon.svg": exceeds 64 KiB`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MAX_ICON_BYTES = 64 * 1024;
|
||||
import { findSvgIconProblem } from "@getpaseo/plugin";
|
||||
|
||||
export async function readPluginProviderIcon(
|
||||
pluginDirectory: string,
|
||||
@@ -20,10 +19,10 @@ export async function readPluginProviderIcon(
|
||||
if (!iconStat.isFile()) {
|
||||
throw iconError(iconPath, "file does not exist or is not a regular file");
|
||||
}
|
||||
if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB");
|
||||
|
||||
const svg = await readFile(resolvedIcon, "utf8");
|
||||
validateSvg(iconPath, svg);
|
||||
const problem = findSvgIconProblem(svg);
|
||||
if (problem) throw iconError(iconPath, problem);
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -38,27 +37,6 @@ function assertInsidePluginDirectory(
|
||||
if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory");
|
||||
}
|
||||
|
||||
function validateSvg(iconPath: string, svg: string): void {
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
|
||||
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) {
|
||||
throw iconError(iconPath, "foreignObject elements are not allowed");
|
||||
}
|
||||
if (/<style(?:\s|>)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed");
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) {
|
||||
throw iconError(iconPath, "event-handler attributes are not allowed");
|
||||
}
|
||||
if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed");
|
||||
|
||||
const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
for (const match of svg.matchAll(hrefPattern)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) {
|
||||
throw iconError(iconPath, "external href references are not allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconError(iconPath: string, reason: string): Error {
|
||||
return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user