diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js
index 23061d1..2f2c842 100644
--- a/packages/server/fs-store.js
+++ b/packages/server/fs-store.js
@@ -55,11 +55,42 @@ export function writeScript(name, content) {
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
}
+/**
+ * Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
+ * @returns {{ filePath: string, contentType: string } | null}
+ */
+export function resolveScriptIcon(name) {
+ assertScriptName(name);
+ const base = name.slice(0, -3);
+ for (const { ext, contentType } of [
+ { ext: "png", contentType: "image/png" },
+ { ext: "jpg", contentType: "image/jpeg" },
+ ]) {
+ const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
+ return { filePath, contentType };
+ }
+ }
+ return null;
+}
+
+export function scriptHasIcon(name) {
+ return resolveScriptIcon(name) != null;
+}
+
export function deleteScript(name) {
assertScriptName(name);
const filePath = path.join(SCRIPTS_DIR, name);
if (!fs.existsSync(filePath)) return false;
+ const icon = resolveScriptIcon(name);
fs.unlinkSync(filePath);
+ if (icon) {
+ try {
+ fs.unlinkSync(icon.filePath);
+ } catch {
+ // ignore missing icon
+ }
+ }
return true;
}
diff --git a/packages/server/scripts/fetch-html.js b/packages/server/scripts/fetch-html.js
index 6947e8f..3b7be2e 100644
--- a/packages/server/scripts/fetch-html.js
+++ b/packages/server/scripts/fetch-html.js
@@ -87,6 +87,7 @@ async function fetchHtml(ctx) {
fetchHtml.meta = {
description: "Fetch HTML and optionally select elements or transform with JSONata",
previewConfigKey: "url",
+ tags: ["HTTP"],
config: {
url: { type: "string", required: true, description: "Page URL" },
selector: { type: "string", required: false, description: "CSS selector" },
diff --git a/packages/server/scripts/fetch-http.js b/packages/server/scripts/fetch-http.js
index 63d5af9..1246036 100644
--- a/packages/server/scripts/fetch-http.js
+++ b/packages/server/scripts/fetch-http.js
@@ -116,6 +116,7 @@ async function fetchHttp(ctx) {
fetchHttp.meta = {
description: "Fetch a URL and store the response body.",
previewConfigKey: "url",
+ tags: ["HTTP"],
config: {
url: { type: "string", required: true, description: "Request URL" },
method: {
diff --git a/packages/server/scripts/fetch-rss-feed.js b/packages/server/scripts/fetch-rss-feed.js
index ecce454..afcb6a5 100644
--- a/packages/server/scripts/fetch-rss-feed.js
+++ b/packages/server/scripts/fetch-rss-feed.js
@@ -69,6 +69,7 @@ async function fetchRssFeed(ctx) {
fetchRssFeed.meta = {
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
previewConfigKey: "url",
+ tags: ["RSS"],
config: {
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
outputVar: {
diff --git a/packages/server/scripts/trigger-workflow.js b/packages/server/scripts/trigger-workflow.js
index ff8bdab..2f478e2 100644
--- a/packages/server/scripts/trigger-workflow.js
+++ b/packages/server/scripts/trigger-workflow.js
@@ -29,6 +29,7 @@ triggerWorkflow.meta = {
description:
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
previewConfigKey: "name",
+ tags: ["trigger"],
config: {
name: {
type: "string",
diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js
index 939ade7..43772b4 100644
--- a/packages/server/src/api/scripts.js
+++ b/packages/server/src/api/scripts.js
@@ -1,3 +1,4 @@
+import fs from "fs";
import {
clearScriptCache,
inspectScriptSource,
@@ -21,11 +22,27 @@ export default function scriptsPluginFactory(registry) {
content == null
? { meta: null, metaError: "script not found" }
: inspectScriptSource(name, content);
- return { name, ...inspected };
+ return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
});
return { scripts };
});
+ fastify.get("/scripts/:name/icon", async (req, reply) => {
+ const { name } = /** @type {{ name: string }} */ (req.params);
+ try {
+ fsStore.assertScriptName(name);
+ } catch (err) {
+ return reply.code(err.statusCode ?? 400).send({ error: err.message });
+ }
+ const icon = fsStore.resolveScriptIcon(name);
+ if (icon == null) return reply.code(404).send({ error: "icon not found" });
+ const body = fs.readFileSync(icon.filePath);
+ return reply
+ .type(icon.contentType)
+ .header("Cache-Control", "private, max-age=60")
+ .send(body);
+ });
+
fastify.get("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
@@ -35,7 +52,7 @@ export default function scriptsPluginFactory(registry) {
}
const content = fsStore.readScript(name);
if (content == null) return reply.code(404).send({ error: "script not found" });
- return { name, content, ...inspectScriptSource(name, content) };
+ return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
});
fastify.put("/scripts/:name", async (req, reply) => {
diff --git a/packages/web/src/components/ScriptIcon.jsx b/packages/web/src/components/ScriptIcon.jsx
new file mode 100644
index 0000000..e543f39
--- /dev/null
+++ b/packages/web/src/components/ScriptIcon.jsx
@@ -0,0 +1,30 @@
+import { useEffect, useState } from "react";
+import { TbFileTypeJs } from "react-icons/tb";
+
+export function scriptIconSrc(name) {
+ return `/api/scripts/${encodeURIComponent(name)}/icon`;
+}
+
+/** Same-name PNG/JPG next to the script, or a JS document icon. */
+export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ setFailed(false);
+ }, [name]);
+
+ const showFile = Boolean(name) && hasIcon !== false && !failed;
+
+ if (!showFile) {
+ return ;
+ }
+
+ return (
+
setFailed(true)}
+ />
+ );
+}
diff --git a/packages/web/src/components/ScriptMetaPanel.jsx b/packages/web/src/components/ScriptMetaPanel.jsx
index 670b48d..8bdea86 100644
--- a/packages/web/src/components/ScriptMetaPanel.jsx
+++ b/packages/web/src/components/ScriptMetaPanel.jsx
@@ -1,3 +1,6 @@
+import { scriptTags } from "../lib/script.js";
+import { TagBadge } from "./TagBadge.jsx";
+
function FieldTable({ title, fields }) {
const entries = Object.entries(fields ?? {});
if (entries.length === 0) return null;
@@ -56,9 +59,18 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
);
}
+ const tags = scriptTags(meta);
+
return (
{meta.description ?
{meta.description}
: null}
+ {tags.length ? (
+
+ {tags.map((tag) => (
+
+ ))}
+
+ ) : null}
diff --git a/packages/web/src/components/TagBadge.jsx b/packages/web/src/components/TagBadge.jsx
new file mode 100644
index 0000000..b1b0770
--- /dev/null
+++ b/packages/web/src/components/TagBadge.jsx
@@ -0,0 +1,17 @@
+import { tagColor } from "../lib/script.js";
+
+export function TagBadge({ tag, className = "", onClick, title }) {
+ const { bg, text } = tagColor(tag);
+ const Comp = onClick ? "button" : "span";
+ return (
+
+ {tag}
+
+ );
+}
diff --git a/packages/web/src/components/workflow/AddScriptDialog.jsx b/packages/web/src/components/workflow/AddScriptDialog.jsx
index a8c2b3c..8ed261c 100644
--- a/packages/web/src/components/workflow/AddScriptDialog.jsx
+++ b/packages/web/src/components/workflow/AddScriptDialog.jsx
@@ -1,5 +1,7 @@
import { useMemo, useState } from "react";
import { useScripts } from "../../api/hooks.js";
+import { ScriptIcon } from "../ScriptIcon.jsx";
+import { scriptTags } from "../../lib/script.js";
export function AddScriptDialog({ open, onClose, onPick }) {
const { data: scripts = [], isLoading } = useScripts();
@@ -12,12 +14,15 @@ export function AddScriptDialog({ open, onClose, onPick }) {
description: typeof s === "string" ? "" : s.meta?.description ?? "",
meta: typeof s === "string" ? null : s.meta,
metaError: typeof s === "string" ? null : s.metaError,
+ hasIcon: typeof s === "string" ? undefined : s.hasIcon,
+ tags: typeof s === "string" ? [] : scriptTags(s.meta),
}));
if (!term) return list;
return list.filter(
(s) =>
s.name.toLowerCase().includes(term) ||
- s.description.toLowerCase().includes(term),
+ s.description.toLowerCase().includes(term) ||
+ s.tags.some((t) => t.toLowerCase().includes(term)),
);
}, [scripts, q]);
@@ -61,9 +66,12 @@ export function AddScriptDialog({ open, onClose, onPick }) {
setQ("");
}}
>
-
{s.name}
-
- {s.metaError ? s.metaError : s.description || "No description"}
+
+
+ {s.name}
+
+ {s.metaError ? s.metaError : s.description || "No description"}
+
diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx
index c4f1630..9aae03b 100644
--- a/packages/web/src/components/workflow/ScriptCard.jsx
+++ b/packages/web/src/components/workflow/ScriptCard.jsx
@@ -10,6 +10,7 @@ import { StatusBadge } from "../../lib/format.jsx";
import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
+import { ScriptIcon } from "../ScriptIcon.jsx";
export function ScriptCard({
step,
@@ -66,6 +67,9 @@ export function ScriptCard({
>
+ {step.kind === "script" && step.script ? (
+
+ ) : null}
{!expanded && preview ? (
{preview}
diff --git a/packages/web/src/lib/script.js b/packages/web/src/lib/script.js
index 8993358..fc64322 100644
--- a/packages/web/src/lib/script.js
+++ b/packages/web/src/lib/script.js
@@ -5,6 +5,7 @@ export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
main.meta = {
description: "",
previewConfigKey: "",
+ tags: [],
config: {},
input: {},
output: {},
@@ -29,6 +30,38 @@ export function normalizeScriptName(name) {
return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`;
}
+export function scriptTags(meta) {
+ if (!Array.isArray(meta?.tags)) return [];
+ return meta.tags.map((t) => String(t).trim()).filter(Boolean);
+}
+
+const TAG_PALETTE = [
+ { bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" },
+ { bg: "color-mix(in oklab, #059669 22%, transparent)", text: "#059669" },
+ { bg: "color-mix(in oklab, #d97706 22%, transparent)", text: "#b45309" },
+ { bg: "color-mix(in oklab, #db2777 22%, transparent)", text: "#db2777" },
+ { bg: "color-mix(in oklab, #7c3aed 22%, transparent)", text: "#7c3aed" },
+ { bg: "color-mix(in oklab, #0d9488 22%, transparent)", text: "#0f766e" },
+ { bg: "color-mix(in oklab, #ea580c 22%, transparent)", text: "#c2410c" },
+ { bg: "color-mix(in oklab, #4f46e5 22%, transparent)", text: "#4338ca" },
+ { bg: "color-mix(in oklab, #dc2626 22%, transparent)", text: "#b91c1c" },
+ { bg: "color-mix(in oklab, #65a30d 22%, transparent)", text: "#4d7c0f" },
+];
+
+function hashTag(tag) {
+ let h = 2166136261;
+ const s = String(tag).toLowerCase();
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+}
+
+export function tagColor(tag) {
+ return TAG_PALETTE[hashTag(tag) % TAG_PALETTE.length];
+}
+
export function prettyJson(value) {
if (value === null || value === undefined) return "";
try {
diff --git a/packages/web/src/pages/ScriptDryRunPage.jsx b/packages/web/src/pages/ScriptDryRunPage.jsx
index 7dfc4e6..58f459d 100644
--- a/packages/web/src/pages/ScriptDryRunPage.jsx
+++ b/packages/web/src/pages/ScriptDryRunPage.jsx
@@ -10,6 +10,7 @@ import {
} from "../api/hooks.js";
import { CodeEditor } from "../components/CodeEditor.jsx";
import { LogViewer } from "../components/LogViewer.jsx";
+import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
import { StatusBadge } from "../lib/format.jsx";
import {
@@ -133,6 +134,7 @@ export function ScriptDryRunPage() {
+
{name}
dry run
{runStatus ? : null}
diff --git a/packages/web/src/pages/ScriptEditPage.jsx b/packages/web/src/pages/ScriptEditPage.jsx
index ef19c12..506e24e 100644
--- a/packages/web/src/pages/ScriptEditPage.jsx
+++ b/packages/web/src/pages/ScriptEditPage.jsx
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useSaveScript, useScript } from "../api/hooks.js";
import { CodeEditor } from "../components/CodeEditor.jsx";
+import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
@@ -137,6 +138,7 @@ export function ScriptEditPage() {
+
{name}