Files
yetone e5f04f38e8 plugin: a plugin on a pre-release whose release is out shows the update on its row, as the Plugins dot already did; versions are ordered as semver orders pre-releases, in Go and in the page
The Plugins dot comes from update.Newer, and the Installed rows (chip,
Update, Update all) from plugins.js's own newer(). They disagreed:

- newer() read only x.y.z, so 2.0.0-beta.1 with 2.0.0 out had the dot
  and no row to update it from. Real case: opencode-antigravity-auth,
  whose latest is 1.6.0 after 1.6.0-beta.0..2.
- update.Newer compared pre-releases as strings, so 1.0.0-beta.10 came
  before 1.0.0-beta.9 (and 1.6.0-10 before 1.6.0-9): a missed update,
  and the other way round, an "update" to an older beta.
- update.Newer didn't parse build metadata, so 1.0.1+build.1 was never
  newer than anything.

Both now order as semver §11: no pre-release comes after any;
identifiers compared one by one, numbers as numbers, a number before a
word, and a shorter list before a longer one it starts. Build metadata
is ignored. A version that isn't x.y.z is never newer, as before.

Tests:
- internal/update TestNewer gains the pairs. On the old Newer 4 fail
  (beta.10/beta.9 both ways, 1.6.0-10/-9, +build).
- internal/gui/tests/plugin-prerelease.test.cjs: seven installed
  plugins, the dot's waiting list as update.Newer gives it, and each
  row's chip and Update button, plus the Update all count, must match.
  On the old plugins.js it fails at "v2.0.0 out" in both engines. No
  strings change, so it's English only.

Reproduced in a sandbox HOME with the real package:
`magpie plugin add opencode-antigravity-auth@1.6.0-beta.2`, then Check
for updates. Before: /api/plugins/updates listed it, and the Plugins
nav had the dot, but the row had no chip and no Update, and there was no
Update all. After: the row shows "v1.6.0 out", Update, and Update all (1).
Update all then installed 1.6.0 and the waiting list emptied.

Run: gofmt; go vet -tags nogui for darwin, linux and windows on
internal/update, internal/plugin, internal/gui and the root package; go
test -tags nogui of the root package, internal/{gui,plugin,provider,
stats,update} under a temp HOME; GUI tests plugin-prerelease,
plugin-updates, plugin-card-update, plugin-check-updates, plugin-market,
plugin-release, plugin-git and plugin-local in Chromium and WebKit.
2026-10-10 10:28:49 +08:00

1242 lines
61 KiB
JavaScript

// Plugins: the market of OpenCode provider plugins — subscriptions magpie
// signs in to through someone else's code. Discover lists the plugins
// magpie suggests (the community repo's list, with what npm says of each
// now) and finds the rest on npm; Installed is what was added, to sign in
// with, update, switch off or remove. Each part is drawn as it comes
// (#488): what's installed from /api/plugins, the plugins suggested from
// /api/plugins/listings, and what npm says of them (versions, downloads)
// from /api/plugins/npm after; a sign-in happens in the Providers add
// sheet, as every other subscription's does.
(() => {
const page = $("#view-plugins");
if (!page) return;
let mine = null; // /api/plugins: what's installed
let listings = null; // /api/plugins/listings: the plugins suggested
let tagged = null; // /api/plugins/github: repositories their authors tagged magpie-plugin, as listings
let topic = "magpie-plugin";
const npm = {}; // /api/plugins/npm: package → what npm says of it
let failed = ""; // why what's installed couldn't be loaded
let failedList = ""; // ...and the plugins suggested
let tab = "discover";
try { tab = localStorage.getItem("magpie.pluginTab") || tab; } catch {}
let query = "";
let hits = null; // npm's answer for query: { q, list } | { q, loading } | { q, error }
let searchTimer = 0;
const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
let editing = null; // { pkg, text, error }: a middleware's options being edited in its row
let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
let checking = false; // Check for updates: npm being asked now
let checked = null; // ...and what it said: { at, by: spec → check }
let shown = "";
const SEARCH = "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3";
const DOWN = "M8 3v7.5 M4.8 7.6 8 10.8l3.2-3.2 M3.5 13h9";
const STAR = "M8 2.2l1.8 3.7 4 .6-2.9 2.8.7 4L8 11.4l-3.6 1.9.7-4L2.2 6.5l4-.6z";
const SHIELD = "M8 2.3 13 4v3.8c0 3-2.1 5.1-5 5.9-2.9-.8-5-2.9-5-5.9V4z M5.8 8l1.6 1.6 2.9-3";
const OUTL = "M9.5 3.5h3v3 M12.5 3.5 7.5 8.5 M11 9.5v3H3.5V5h3";
const PLUG = "M6 2v3 M10 2v3 M4.5 5h7v2.5a3.5 3.5 0 0 1-7 0z M8 11v3";
const PIPE = "M1.5 8h3 M11.5 8h3 M4.5 4.5h7v7h-7z";
const FOLDER = "M14.7 12.7a1.3 1.3 0 0 1-1.3 1.3H2.6a1.3 1.3 0 0 1-1.3-1.3V3.3A1.3 1.3 0 0 1 2.6 2h3.3l1.3 2h6.2a1.3 1.3 0 0 1 1.3 1.3z";
const name = (spec) => { const i = spec.lastIndexOf("@"); return i > 0 && !spec.startsWith(".") && !spec.includes("/", i) ? spec.slice(0, i) : spec; };
// a plugin added from a folder is a folder's path, not a package name
const isPath = (p) => /^file:\/\//.test(p) || /^\./.test(p) || /^\//.test(p) || /^[A-Za-z]:[\\/]/.test(p);
// ...and is known by the folder's name, not its whole path
const label = (spec) => { const n = name(spec); return isPath(n) ? n.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || n : n; };
// one added from a git repository (github:owner/repo, git+https://…), as
// the plugin store tells them apart: known by the package it installed
const isGit = (p) => !isPath(p) && (/^(?:(?:github|gitlab|bitbucket):|git\+[a-z]+:\/\/|git:\/\/)\S+$/.test(p) || /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com|bitbucket\.org)\/\S+$/.test(p) || /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+(?:#\S+)?$/.test(p));
// ...and its page on the web, for the plugin's Source link
const gitWeb = (p) => {
const s = p.replace(/#.*$/, "");
const m = /^(github|gitlab|bitbucket):(.+)$/.exec(s) || (/^[^:]+\/[^:]+$/.test(s) && ["", "github", s]);
if (m) return "https://" + { github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org" }[m[1]] + "/" + m[2];
const u = s.replace(/^git\+/, "").replace(/\.git$/, "");
return /^https?:\/\//.test(u) ? u : "";
};
const lang = () => (document.documentElement.lang || "en").slice(0, 2);
const summary = (l) => l.summary?.[lang()] || l.summary?.en || l.npm?.description || "";
const count = (n) => n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k" : String(n || 0);
// a repository tagged magpie-plugin is installed by whichever spec
// names it (github:owner/repo, its page's URL), or as the package its
// package.json names
const ghRepo = (spec) => { const m = /^(?:github:|https?:\/\/(?:www\.)?github\.com\/)([^/#\s]+\/[^/#\s]+?)(?:\.git)?(?:[/#].*)?$/i.exec(spec) || (isGit(spec) && /^([^:/#]+\/[^:/#]+)(?:#.*)?$/.exec(spec)); return m ? m[1].toLowerCase() : ""; };
const entryOf = (pkg) => {
const g = (tagged || []).find((x) => x.package === pkg)?.github;
return mine?.plugins?.find((e) => name(e.spec) === pkg || (g && (ghRepo(e.spec) === g.repo.toLowerCase() || (g.package && isGit(e.spec) && e.package === g.package))));
};
// npm has the versions of a package: not a folder's, nor a git one's
const onNPM = (spec) => !isPath(spec) && !isGit(spec);
// the providers a plugin signs in to, as the add sheet knows them
const subsOf = (pkg) => (providers?.plugins || []).filter((x) => name(x.spec) === pkg);
// whether version a comes after b, in semver's order, the same as
// update.Newer, which puts the dot on Plugins: a pre-release before its
// release, numbers in it as numbers ("beta.10" after "beta.9"), a number
// before a word, a shorter list first; build metadata ("+…") ignored. A
// version that isn't x.y.z is never newer.
const newer = (a, b) => {
const p = (v) => {
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]*))?(?:\+.*)?$/.exec(String(v || "").trim());
return m && { n: [+m[1], +m[2], +m[3]], pre: m[4] || "" };
};
const [x, y] = [p(a), p(b)];
if (!x || !y) return false;
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
if (x.pre === y.pre) return false;
if (!x.pre || !y.pre) return !x.pre;
const [s, u] = [x.pre.split("."), y.pre.split(".")];
for (let i = 0; i < s.length && i < u.length; i++) {
const [d, e] = [/^\d+$/.test(s[i]), /^\d+$/.test(u[i])];
if (d && e) { if (+s[i] !== +u[i]) return +s[i] > +u[i]; }
else if (d !== e) return e;
else if (s[i] !== u[i]) return s[i] > u[i];
}
return s.length > u.length;
};
function glyph(d, size = 14, stroke = 1.5) { return svg(d, size, stroke); }
// kindChip says what a plugin is: a provider signs in to a subscription
// and serves its models; middleware runs in the gateway on every agent's
// requests and replies, whatever serves them
function kindChip(mw) {
const c = el("span", "pm-chip kind " + (mw ? "mw" : "pv"));
c.append(glyph(mw ? PIPE : PLUG, 10, 1.5), el("span", "", mw ? t("Middleware") : t("Provider")));
c.title = mw ? t("Runs in magpie's gateway on the requests your agents send and on the replies") : t("Signs in to a subscription and serves its models");
return c;
}
function logo(ic, big, mw) {
const box = el("span", "pm-logo" + (big ? " big" : ""));
if (ic) box.append(icon(ic));
else box.append(glyph(mw ? PIPE : PUZZLE, big ? 22 : 17, 1.4));
return box;
}
// npm's answers, put where the cards and rows read them: on the
// listing and the entry themselves, so a plugin's page opened before
// npm answered has them too
function merge() {
for (const l of listings || []) if (npm[l.package]) l.npm = npm[l.package];
for (const e of mine?.plugins || []) if (onNPM(e.spec) && npm[name(e.spec)]?.version) e.latest = npm[name(e.spec)].version;
}
async function loadMine() {
try {
mine = await api("plugins");
failed = "";
} catch (e) {
failed = e.message;
}
merge();
}
// a repository tagged magpie-plugin as the market's cards read a
// listing: its owner as the publisher, its version, and installed by its
// spec (its npm name when its author published it there from the
// repository, else github:owner/repo)
const ghListing = (r) => ({
package: r.spec, name: r.repo.split("/")[1], kind: r.kind, github: r, icon: r.icon || undefined,
summary: r.description ? { en: r.description } : undefined,
npm: { version: r.version || "", publisher: r.owner, repository: r.url, license: r.license, weekly: 0 },
});
async function loadTagged() {
try {
const r = await api("plugins/github");
tagged = (r.repos || []).map(ghListing);
if (r.topic) topic = r.topic;
} catch { tagged = tagged || []; }
}
async function loadListings() {
try {
const r = await api("plugins/listings");
listings = r.listings || [];
failedList = "";
} catch (e) {
failedList = e.message;
}
merge();
}
// what npm says of the plugins suggested and those installed: drawn
// when it comes, never waited on
async function askNPM(only) {
const names = [...new Set([...(listings || []).map((l) => l.package), ...(mine?.plugins || []).filter((e) => onNPM(e.spec)).map((e) => name(e.spec))])]
.filter((n) => !only || !npm[n]);
if (!names.length) return;
try {
const r = await api("plugins/npm?names=" + encodeURIComponent(names.join(",")));
Object.assign(npm, r.npm || {});
} catch {}
merge();
// one npm said nothing of, now or ever: as one it doesn't have
for (const l of listings || []) if (!l.npm) l.npm = { weekly: 0 };
drawBody();
}
async function load() {
if (!mine && !listings) draw();
const parts = [
loadMine().then(() => { draw(); window.renderPluginDot?.(); }),
loadListings().then(draw),
loadTagged().then(drawBody),
providers ? null : loadProviders().then(drawBody, () => {}),
];
await Promise.all(parts);
askNPM();
}
window.loadPlugins = load;
// pluginQuery: Discover, looking for q (the add sheet found nothing by it)
window.pluginQuery = (q) => {
if (q === query && !q) return;
query = q;
tab = "discover";
clearTimeout(searchTimer);
hits = q.trim().length >= 2 ? { q: q.trim(), loading: true } : null;
if (hits) searchNPM(hits.q);
draw();
};
async function refresh() {
try {
const [m, p] = await Promise.all([api("plugins"), api("providers")]);
mine = m;
providers = p;
merge();
// the Providers page owns the dialog as it draws: only drawn when shown
if (view === "providers") renderProviders();
} catch (e) { status(e.message, "err"); }
draw();
window.renderPluginDot?.();
askNPM(true);
}
async function act(pkg, op, body, done) {
if (busy.size && op !== "off") return;
busy.set(pkg, op);
draw();
try {
await api("plugins/" + op, body);
busy.delete(pkg);
await refresh();
if (done) done();
} catch (e) {
busy.delete(pkg);
draw();
status(e.message, "err");
}
}
// Check for updates: npm asked now, not what it said within the hour,
// for each installed plugin's newest version. Each row then says so (an
// update, up to date, or why it couldn't be checked), and its Update, or
// Update all, installs it as before. Nothing is installed by the check.
async function checkUpdates() {
if (checking || busy.size) return;
checking = true;
draw();
try {
const r = await api("plugins/check", {});
mine = r.state || mine;
const by = {};
for (const c of r.plugins || []) {
by[c.spec] = c;
// npm's answer now is the one the rows read
if (c.latest) npm[c.package] = { ...(npm[c.package] || {}), version: c.latest };
}
checked = { at: r.at, by };
merge();
const asked = (r.plugins || []).filter((c) => c.status === "update" || c.status === "current" || c.status === "unknown");
const ups = asked.filter((c) => c.status === "update" && !c.off).length;
const unknown = asked.filter((c) => c.status === "unknown");
if (asked.length && unknown.length === asked.length) {
status(t("Couldn't check for updates: {error}", { error: whyText(unknown[0]) }), "err", 8000);
} else {
let msg = ups ? (ups === 1 ? t("1 plugin has an update") : t("{n} plugins have updates", { n: ups })) : t("Every plugin is up to date");
if (unknown.length) {
msg += " · " + t("{n} couldn't be checked: {error}", { n: unknown.length, error: whyText(unknown[0]) });
status(msg, "warn", 8000);
} else status(msg, "ok");
}
} catch (e) {
status(t("Couldn't check for updates: {error}", { error: e.message }), "err", 8000);
}
checking = false;
draw();
window.renderPluginDot?.();
}
// why npm said nothing of a plugin, in the page's words
function whyText(c) {
switch (c.why) {
case "offline": return t("npm couldn't be reached — check your connection or proxy");
case "limited": return t("npm is turning requests away for now (too many); try again in a few minutes");
case "missing": return t("npm doesn't have it");
default: return t("npm answered with an error: {error}", { error: c.error || "" });
}
}
const checkedAt = () => checked ? new Date(checked.at).toLocaleTimeString(document.documentElement.lang || undefined, { hour: "2-digit", minute: "2-digit" }) : "";
// the built-ins with accounts this plugin could run in their place
const movableOf = (pkg) => (mine?.movable || []).filter((c) => c.package === pkg);
// move: a built-in's accounts onto its plugin, installing it if need be,
// as its editor's Runs on does
async function move(c) {
if (busy.size) return;
busy.set(c.package, "move");
asking = null;
draw();
try {
providers = await api("provider/move", { id: c.id });
busy.delete(c.package);
await refresh();
const now = providers.providers.find((x) => x.id === c.id);
const accts = now?.account?.logins?.length || c.accounts, models = (now?.models || []).filter((x) => x.on).length;
status(t("{name} now runs on its plugin — {accounts}, {models}. Its editor in Providers moves it back.", {
name: c.name,
accounts: t(accts === 1 ? "{n} account" : "{n} accounts", { n: accts }),
models: t(models === 1 ? "{n} model" : "{n} models", { n: models }),
}), "ok", 9000);
} catch (e) {
busy.delete(c.package);
draw();
// no editor here to sign in above: say where
const why = e.why?.code === "lapsed"
? t("every {name} account needs signing in again. Sign one in under Providers, then move.", { name: c.name })
: moveWhy({ why: e.why, error: e.message }, { name: c.name });
status(t("It stays built-in: {error}", { error: why }), "err", 12000);
}
}
const moveButton = (b, c, here) => {
// one word beside the name, as Install is: the card already says whose
// accounts, the title says how many and what it does (a whole sentence
// was a solid bar under the card that broke onto two lines)
b.classList.add("get", "move");
b.textContent = t("Move");
b.title = (here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts }))
+ " — " + t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name });
b.disabled = busy.size > 0;
b.onclick = (ev) => { ev.stopPropagation(); move(c); };
return b;
};
const install = (pkg, nm) => act(pkg, "add", { spec: pkg }, () => {
const subs = subsOf(pkg);
status(subs.length ? t("{name} is installed — sign in to use it", { name: nm }) : t("{name} is installed", { name: nm }), "ok");
});
// the one button a plugin shows: what to do with it next
function actionFor(pkg, nm, listed) {
const b = el("button", "pm-act");
const e = entryOf(pkg);
const op = busy.get(pkg);
if (op === "add" || op === "upgrade" || op === "move") {
b.classList.add("busy");
if (op === "move") b.classList.add("move");
b.append(el("span", "spin"), el("span", "", op === "move" ? t("Moving…") : op === "upgrade" ? t("Updating…") : mine?.bun ? t("Installing…") : t("Getting Bun…")));
b.disabled = true;
if (!mine?.bun) b.title = t("Plugins run on Bun {v}, downloaded once", { v: mine?.bunVersion || "" });
return b;
}
// not known yet whether it's installed, or whether npm has it: a
// button that waits, rather than one that says the wrong thing
if (!mine || (!e && listed && !listed.npm)) {
b.classList.add("busy");
b.append(el("span", "spin"));
b.disabled = true;
return b;
}
if (!e) {
if (listed && !listed.github && !listed.npm?.version) {
b.textContent = t("Coming soon");
b.disabled = true;
b.title = t("Not on npm yet");
return b;
}
const c = movableOf(pkg)[0];
if (c) return moveButton(b, c);
b.classList.add("get");
b.textContent = t("Install");
b.disabled = busy.size > 0;
b.onclick = (ev) => { ev.stopPropagation(); install(pkg, nm); };
return b;
}
if (e.off) {
b.textContent = t("Switch on");
b.onclick = (ev) => { ev.stopPropagation(); act(pkg, "off", { spec: e.spec, off: false }); };
return b;
}
const subs = subsOf(pkg);
const out = subs.find((x) => !x.signedIn);
// signed in to nothing yet, with a built-in's accounts to bring
const c = !subs.some((x) => x.signedIn) && movableOf(pkg)[0];
if (c) return moveButton(b, c, true);
// one of its subscriptions signed in (Qoder, beside Qoder CN; #556): the
// card says it is, rather than a Sign in that reads as signed out; the
// Installed row offers the others by name
const on = subs.find((x) => x.signedIn);
if (out && !on) {
b.classList.add("go");
b.textContent = t("Sign in");
b.title = subs.length > 1 ? t("Sign in to {name}", { name: out.name }) : "";
b.onclick = (ev) => { ev.stopPropagation(); pluginSignIn(out.id); };
return b;
}
if (on) {
b.classList.add("done");
b.append(glyph(CHECK, 11, 2), el("span", "", t("Signed in")));
b.title = t("Open {name} in Providers", { name: on.name });
b.onclick = (ev) => { ev.stopPropagation(); openProvider(on.id); };
return b;
}
b.classList.add("done");
b.append(glyph(CHECK, 11, 2), el("span", "", t("Installed")));
b.disabled = true;
return b;
}
function card(l) {
const c = el("div", "pm-card");
c.tabIndex = 0;
c.setAttribute("role", "button");
c.dataset.pkg = l.package;
const e = entryOf(l.package);
if (e) c.classList.add("have");
const top = el("div", "pm-top");
const who = el("div", "pm-who");
const nm = el("div", "pm-name");
nm.append(el("b", "", l.name));
who.append(nm);
const by = el("div", "pm-by");
if (l.community) {
const v = el("span", "pm-verified");
v.append(glyph(SHIELD, 11, 1.5), el("span", "", t("magpie community")));
v.title = t("Written for magpie by its community, in github.com/magpie-community/plugins");
by.append(v);
} else by.append(el("span", "", l.npm?.publisher || l.package));
who.append(by);
if (l.github) c.classList.add("gh");
top.append(logo(l.icon, false, l.kind === "middleware"), who, actionFor(l.package, l.name, l));
const sum = el("p", "pm-sum", summary(l));
const meta = el("div", "pm-meta");
meta.append(kindChip(l.kind === "middleware"));
if (l.github) meta.append(unofficial(), stars(l.github.stars));
if (l.npm?.weekly) {
const d = el("span", "pm-dl");
d.append(glyph(DOWN, 11, 1.5), el("span", "", t("{n}/week", { n: count(l.npm.weekly) })));
d.title = t("{n} downloads on npm last week", { n: l.npm.weekly.toLocaleString() });
meta.append(d);
}
if (l.npm?.version) meta.append(el("span", "pm-ver", "v" + (e?.version || l.npm.version)));
if (e && e.latest && e.version && newer(e.latest, e.version)) {
// the chip says there is an update, so it is the one that brings it
// (Discord: 显示更新但是好像没有更新按钮)
const b = busy.get(l.package) || busy.get(e.spec);
const up = el("button", "pm-chip up", b === "upgrade" ? t("Updating…") : t("Update to {v}", { v: "v" + e.latest }));
up.type = "button";
up.disabled = busy.size > 0 || checking || !!e.off;
up.onclick = (ev) => {
ev.stopPropagation();
act(l.package, "upgrade", { spec: e.spec }, () => status(t("{name} updated to v{v}", { name: l.name, v: e.latest }), "ok"));
};
up.onkeydown = (ev) => ev.stopPropagation();
meta.append(up);
}
if (e?.error && !e.off) meta.append(el("span", "pm-chip bad", t("Didn't load")));
else if (e?.off) meta.append(el("span", "pm-chip", t("Off")));
if (l.replaces) {
const b = el("span", "pm-chip soft", t("Also built in"));
b.title = t("magpie also signs in to this itself, for now; the plugin keeps it working if the built-in one is retired");
meta.append(b);
}
c.append(top, sum, meta);
c.onclick = () => detail(l);
c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } };
return c;
}
// a repository's author put it here, nobody reviewed it: said on its card
function unofficial() {
const u = el("span", "pm-chip warn");
u.append(el("span", "dot"), el("span", "", t("Unofficial")));
u.title = t("Tagged {topic} on GitHub by its author. Nobody at magpie has read it: read its code before you install it", { topic });
return u;
}
function stars(n) {
const d = el("span", "pm-dl");
d.append(glyph(STAR, 11, 1.4), el("span", "", count(n)));
d.title = t("{n} stars on GitHub", { n: (n || 0).toLocaleString() });
return d;
}
function skeleton(n) {
const g = el("div", "pm-grid");
for (let i = 0; i < n; i++) {
const c = el("div", "pm-card ghost");
c.append(el("div", "g1"), el("div", "g2"), el("div", "g3"));
g.append(c);
}
return g;
}
function section(title, hint, list) {
const box = el("section", "pm-sec");
const h = el("div", "pm-sechead");
h.append(el("h3", "", title));
if (hint) h.append(el("span", "", hint));
const g = el("div", "pm-grid");
for (const l of list) g.append(card(l));
box.append(h, g);
return box;
}
// the head and its search field are made once and drawn again in
// place: the field is never taken out of the page, or a key an IME
// commits while it is out lands twice (#1055)
const headBox = el("div", "lib-head pm-head");
const find = el("label", "pm-find");
const q = el("input");
q.type = "search";
q.spellcheck = false;
q.autocomplete = "off";
q.oninput = () => {
query = q.value;
if (tab !== "discover") { tab = "discover"; draw(); }
clearTimeout(searchTimer);
const s = query.trim();
if (s.length < 2) hits = null;
else {
hits = { q: s, loading: true };
searchTimer = setTimeout(() => searchNPM(s), 350);
}
drawBody();
};
q.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && q.value) { q.value = ""; q.oninput(); } };
find.append(glyph(SEARCH, 13, 1.6), q);
function head() {
const n = mine?.plugins?.length || 0;
const tabs = segs([["discover", t("Discover")], ["installed", t("Installed") + (n ? " · " + n : "")]], tab, (id) => {
tab = id;
try { localStorage.setItem("magpie.pluginTab", id); } catch {}
drawBody();
});
tabs.classList.add("lib-tabs");
q.placeholder = t("Search plugins and npm…");
if (q.value !== query) q.value = query;
q.setAttribute("aria-label", t("Search plugins"));
replaceKeeping(headBox, [tabs, el("span", "grow"), mirrorSwitch(), find]);
headBox.classList.toggle("stuck", page.scrollTop > 0);
return headBox;
}
// 「国内镜像」: the list, npm's packages and answers, and Bun asked of
// mirrors in China first (npmmirror, jsDelivr); none before what's
// installed has come, which says how it is set
let mirrorSaving = false;
function mirrorSwitch() {
const on = !!mine?.mirror;
const b = el("button", "pm-mirror" + (on ? " on" : ""));
b.type = "button";
b.hidden = !mine;
b.disabled = mirrorSaving;
b.setAttribute("role", "switch");
b.setAttribute("aria-checked", on ? "true" : "false");
b.title = t("Download the plugin list, plugins and Bun from mirrors in China first (npmmirror, jsDelivr), their official addresses after. Each is a copy of the very file, checked as the original is");
const sw = el("span", "lib-switch" + (on ? " on" : ""));
sw.append(el("i"));
b.append(el("span", "", t("Mirrors in China")), sw);
b.onclick = async () => {
if (mirrorSaving || !mine) return;
mirrorSaving = true;
mine.mirror = !on;
draw();
try {
const r = await api("plugins/mirror", { on: !on });
mine.mirror = r.mirror;
// the list again, now from the mirror, and npm's answers with it
if (r.mirror) loadListings().then(() => { draw(); askNPM(); });
} catch (e) {
mine.mirror = on;
status(t(e.message), "err");
}
mirrorSaving = false;
draw();
};
return b;
}
async function searchNPM(s) {
try {
const r = await api("plugins/search?q=" + encodeURIComponent(s));
if (hits?.q === s) hits = { q: s, list: r.hits };
} catch (e) {
if (hits?.q === s) hits = { q: s, error: e.message };
}
if (hits?.q === s) drawBody();
}
page.addEventListener("scroll", () => page.querySelector(".pm-head")?.classList.toggle("stuck", page.scrollTop > 0), { passive: true });
let body = null;
function draw() {
const scroll = page.scrollTop;
body = el("div", "lib-body pm-body");
replaceKeeping(page, [head(), body]);
drawBody();
page.scrollTop = scroll;
}
function drawBody() {
if (!body) return;
const key = tab + (query ? ":q" : "");
body.classList.toggle("enter", shown !== key);
shown = key;
body.replaceChildren();
// the search field keeps its focus: only the body is redrawn while typing
if (tab === "installed") return drawInstalled();
if (failedList && !listings) {
body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failedList })));
return;
}
if (!listings) {
body.append(intro(), skeleton(6));
return;
}
const f = query.trim().toLowerCase();
const match = (l) => !f || [l.name, l.package, summary(l), (l.providers || []).join(" "), l.npm?.publisher || ""].join(" ").toLowerCase().includes(f);
const ls = listings.filter(match);
// a tagged repository the market already lists is the market's card
const listed = new Set(listings.map((l) => l.package));
const gh = (tagged || []).filter((l) => !(l.github.package && listed.has(l.github.package)) && match(l));
if (!f) {
body.append(intro());
// magpie's community's alone: others' plugins are found by a search
const ours = ls.filter((l) => l.community && l.kind !== "middleware");
const mws = ls.filter((l) => l.community && l.kind === "middleware");
if (ours.length) body.append(section(t("Subscriptions"), t("written for magpie, checked against its own sign-ins"), ours));
if (mws.length) body.append(section(t("Gateway middleware"), t("runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it"), mws));
if (gh.length) body.append(section(t("Unofficial, on GitHub"), t("repositories their authors tagged {topic} — nobody has reviewed them; read the code before you install one", { topic }), gh));
body.append(manual());
return;
}
if (ls.length) body.append(section(t("Suggested"), "", ls));
if (gh.length) body.append(section(t("Unofficial, on GitHub"), t("repositories their authors tagged {topic} — nobody has reviewed them; read the code before you install one", { topic }), gh));
const known = new Set([...listings.map((l) => l.package), ...(tagged || []).map((l) => l.github.package).filter(Boolean)]);
const box = el("section", "pm-sec");
const h = el("div", "pm-sechead");
h.append(el("h3", "", t("On npm")), el("span", "", t("OpenCode plugins and pi packages anyone published — read what one does before you install it")));
box.append(h);
if (!hits) box.append(el("p", "pm-note", t("Type two letters or more to search npm")));
else if (hits.loading) box.append(skeleton(2));
else if (hits.error) box.append(el("p", "pm-note bad", hits.error));
else {
const list = hits.list.filter((x) => !known.has(x.package));
if (!list.length) box.append(el("p", "pm-note", ls.length ? t("Nothing else on npm") : t("No plugin called “{q}”", { q: query.trim() })));
const g = el("div", "pm-grid");
for (const x of list) g.append(card({ package: x.package, name: x.package, npm: x }));
box.append(g);
}
body.append(box);
}
function intro() {
const box = el("div", "pm-intro");
const text = el("div", "pm-introtext");
text.append(el("h2", "", t("Subscriptions and gateway middleware")));
text.append(el("p", "", t("Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.")));
const mw = el("p", "");
mw.append(document.createTextNode(t("A plugin can also be middleware in magpie's gateway: it reads and rewrites what agents send and get back, for every provider.") + " "));
// the docs in the reader's language: the site has en, zh and ja
const how = el("a", "pm-link", "");
how.href = "https://usemagpie.ai/docs/" + ({ zh: "zh/", ja: "ja/" }[lang()] || "") + "plugins#middleware";
how.append(el("span", "", t("Write a middleware")), glyph(OUTL, 11, 1.5));
how.onclick = (ev) => { ev.preventDefault(); api("open", { url: how.href }).catch(() => {}); };
mw.append(how);
text.append(mw);
const trust = el("p", "pm-trust");
trust.append(glyph(SHIELD, 12, 1.5), el("span", "", t("A plugin is someone else's code with your sign-in or your requests: install the ones you trust.")));
text.append(trust);
const art = el("div", "pm-art");
for (const ic of ["zcode", "githubcopilot", "gemini-color", "zed", "kiro-color"]) art.append(logo(ic));
box.append(text, art);
return box;
}
// a package or a folder the market doesn't list
function manual() {
const box = el("form", "pm-manual");
box.append(el("span", "pm-mlabel", t("Have one in mind?")));
const spec = el("input", "pm-minput");
spec.placeholder = t("npm package, GitHub repo (github:owner/repo) or a folder");
spec.spellcheck = false;
spec.autocomplete = "off";
spec.setAttribute("aria-label", t("Plugin"));
spec.onkeydown = (ev) => ev.stopPropagation();
const go = el("button", "text primary", t("Install"));
go.type = "submit";
go.disabled = busy.size > 0;
if (busy.has("+")) { go.textContent = t("Installing…"); spec.disabled = true; }
box.onsubmit = (ev) => {
ev.preventDefault();
const s = spec.value.trim();
if (!s || busy.size) return;
act("+", "add", { spec: s }, () => status(t("{name} is installed", { name: s }), "ok"));
};
box.append(spec);
// a folder is picked, not typed, where magpie can show the system's
// picker (`magpie web` can't, and the market says so)
if (mine?.picker) box.append(browse(spec, go));
box.append(go);
return box;
}
// the system's folder picker: what it answers goes in the field, for the
// Install beside it
function browse(spec, go) {
const b = el("button", "pm-browse");
b.type = "button";
b.append(glyph(FOLDER, 14, 1.5));
b.title = t("Choose a folder on this computer");
b.setAttribute("aria-label", t("Choose a folder"));
b.disabled = busy.size > 0 || spec.disabled;
b.onclick = async () => {
b.disabled = true;
try {
const r = await api("plugins/choose", {});
if (r.dir) {
spec.value = r.dir;
spec.focus();
}
} catch (e) {
status(e.message, "err");
} finally {
b.disabled = busy.size > 0 || spec.disabled;
}
};
return b;
}
function drawInstalled() {
if (failed && !mine) {
body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failed })));
return;
}
if (!mine) { body.append(skeleton(2)); return; }
const es = mine.plugins || [];
if (!es.length) {
const none = el("div", "pm-none");
none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Install a subscription or a middleware from Discover; a subscription signs in from here.")));
const b = el("button", "pm-act get", t("Discover plugins"));
b.onclick = () => { tab = "discover"; draw(); };
none.append(b);
body.append(none);
return;
}
// by name, in the order the reader picked (#481), not the order they
// were installed in
const list = el("div", "list pm-list");
const fill = () => list.replaceChildren(...[...es].sort(byName(sortOf("plugins"), shownName)).map(installedRow));
if (es.length > 1) {
const h = el("div", "row-head pm-listhead");
h.append(el("span", "grow"), sortBy("plugins", NAME_SORTS, fill));
body.append(h);
}
fill();
body.append(list);
const foot = el("div", "pm-foot");
const outdated = es.filter((e) => e.latest && e.version && newer(e.latest, e.version));
foot.append(el("span", "", mine.bun ? t("Plugins run on Bun {v}", { v: mine.bunVersion }) : ""), el("span", "grow"));
// only npm has versions to ask for: a folder's or a git one's has none
if (es.some((e) => onNPM(e.spec))) {
const c = el("button", "text pm-check" + (checking ? " busy" : ""));
if (checking) c.append(el("span", "spin"));
c.append(el("span", "", checking ? t("Checking…") : t("Check for updates")));
c.title = t("Ask npm now for each plugin's newest version") + (checked ? "\n" + t("Last checked {time}", { time: checkedAt() }) : "");
c.disabled = checking || busy.size > 0;
c.onclick = () => checkUpdates();
foot.append(c);
}
if (outdated.length) {
const up = el("button", "text", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length }));
up.disabled = busy.size > 0 || checking;
up.onclick = () => act("*", "update", {}, () => status(t("Plugins updated"), "ok"));
foot.append(up);
}
body.append(foot);
}
// an installed plugin's name as its row shows it: the market's, else the
// package's or the folder's
function shownName(e) {
const pkg = name(e.spec);
return (listings || []).find((x) => x.package === pkg)?.name || (isGit(e.spec) && e.package) || label(e.spec);
}
function installedRow(e) {
const pkg = name(e.spec);
const l = (listings || []).find((x) => x.package === pkg);
const r = el("div", "row pm-row" + (e.off ? " off" : ""));
const who = el("div", "who");
const nm = el("div", "name");
nm.append(el("span", "", shownName(e)));
if (e.version) nm.append(el("span", "pm-ver", "v" + e.version));
const ck = checked?.by[e.spec];
if (e.latest && e.version && newer(e.latest, e.version)) {
const c = el("span", "pm-chip up", t("v{v} out", { v: e.latest }));
c.title = t("v{have} installed, v{v} on npm", { have: e.version, v: e.latest });
nm.append(c);
} else if (ck?.status === "unknown") {
// asked just now, and npm didn't say: the row says why
const c = el("span", "pm-chip warn");
c.append(el("span", "dot"), el("span", "", t("Couldn't check")));
c.title = whyText(ck);
nm.append(c);
} else if (e.autoUpdated && e.autoUpdated.to === e.version) {
// magpie updated it by itself lately: the row says so, quietly
const c = el("span", "pm-chip soft", t("Auto-updated"));
c.title = t("magpie updated it from v{from} to v{to} on {date}", { from: e.autoUpdated.from, to: e.autoUpdated.to, date: new Date(e.autoUpdated.at).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) });
nm.append(c);
} else if ((ck?.status === "current" || ck?.status === "update") && e.version) {
const c = el("span", "pm-chip soft", t("Up to date"));
c.title = t("v{v} is the newest on npm", { v: e.version }) + "\n" + t("Last checked {time}", { time: checkedAt() });
nm.append(c);
}
// the built-in subscriptions moved onto it: taking it away moves them
// back, so the row says it carries them
const moved = (e.moved || []).map((id) => SUBS.find((x) => x.agent === id)?.name || id);
const names = moved.join(t(", "));
if (moved.length && !e.off) {
const c = el("span", "pm-chip moved");
c.append(el("span", "dot"), el("span", "", t("Serves {names}", { names })));
c.title = t("{names} runs on this plugin in place of the built-in", { names });
nm.append(c);
}
// gateway middleware runs in magpie's gateway, not as a subscription:
// the row says so, and how it is doing, on a line of its own
const mw = e.middleware;
if (mw || e.isMiddleware) nm.append(kindChip(true));
if (e.providers?.length || !(e.middlewareOnly || (mw && !e.isMiddleware))) nm.append(kindChip(false));
who.append(nm);
const subs = subsOf(pkg);
const sub = el("div", "sub");
const cands = e.off ? [] : movableOf(pkg);
// a provider another installed plugin signs in to as well (neiko on
// Discord: a third-party plugin and their own one both name it). One
// of them serves it; the row says which, and the other gets a way to
// take it over, rather than the provider just going missing
const clashes = e.off || e.error ? [] : e.clashes || [];
const nameOfSpec = (spec) => { const o = (mine?.plugins || []).find((x) => x.spec === spec); return o ? shownName(o) : label(spec); };
const ask = asking?.pkg === pkg && !busy.size && (asking.op === "move" ? cands.length : moved.length) ? asking.op : "";
if (ask === "move") {
sub.textContent = t("{names} can run on it again, with the same accounts.", { names: cands.map((c) => c.name).join(t(", ")) });
sub.classList.add("ask");
} else if (ask) {
sub.textContent = t(ask === "remove" ? "{names} goes back to the built-in, with its accounts, before the plugin is removed." : "{names} goes back to the built-in, with its accounts, before the plugin is switched off.", { names });
sub.classList.add("ask");
} else if (e.off) sub.textContent = t("Off");
else if (e.error) { sub.textContent = t("Didn't load: {error}", { error: e.error }); sub.classList.add("bad"); sub.title = e.error; }
else if (subs.length) {
for (const [i, x] of subs.entries()) {
if (i) sub.append(el("span", "sep", " · "));
const s = el("span", "pm-sub" + (x.signedIn ? " in" : ""));
// its built-in runs it, with its accounts: nothing to fix here
const builtin = !x.signedIn && cands.some((c) => c.id === x.pid);
s.append(el("span", "dot"), el("span", "", x.signedIn ? t("{name}: signed in", { name: x.name }) : builtin ? t("{name}: on magpie's built-in", { name: x.name }) : t("{name}: not signed in", { name: x.name })));
if (builtin) s.title = t("{name} runs on magpie's built-in, with your accounts; Move {name} here runs it on this plugin", { name: x.name });
else if (!x.signedIn && subs.some((y) => y.signedIn)) s.title = t("{name} is a subscription of its own; {other} works without it", { name: x.name, other: subs.find((y) => y.signedIn).name });
sub.append(s);
}
} else if (mw && !e.providers.length) {
// only middleware: its own line says what it does
} else if (clashes.length && !e.providers.length) {
// its own line below says whose it is
} else sub.textContent = e.providers.length ? t("Signs in to {names}", { names: e.providers.join(t(", ")) }) : t("Signs in to nothing magpie can use");
if (sub.textContent) who.append(sub);
if (!ask) for (const c of clashes) who.append(clashLine(e, c, nameOfSpec));
if (mw && !e.off && !ask) who.append(mwLine(mw));
if (mw && !e.off && !ask && editing?.pkg === pkg) who.append(optionsEditor(e));
r.append(logo(l?.icon || subs[0]?.icon, false, e.middlewareOnly), who);
const val = el("div", "val");
const b = busy.get(pkg) || busy.get(e.spec);
if (e.latest && e.version && newer(e.latest, e.version) && !e.off) {
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
up.disabled = busy.size > 0 || checking;
// the community's would come by itself: Update brings it now
if (pkg.startsWith("@magpie-community/") && !/@(?!latest$)[^@/]+$/.test(e.spec.slice(pkg.length))) up.title = t("magpie updates it by itself within the hour; Update does it now");
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} updated to v{v}", { name: l?.name || pkg, v: e.latest }), "ok"));
val.append(up);
} else if (isGit(e.spec) && !e.off) {
// npm has no newer version of a git one to offer: Update fetches
// its repository again
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
up.title = t("Fetches it from {repo} again", { repo: e.spec });
up.disabled = busy.size > 0;
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} is up to date", { name: e.package || e.spec }), "ok"));
val.append(up);
}
const here = !e.off && !e.error && !subs.some((x) => x.signedIn) && cands[0];
if (here && !ask) {
const mv = el("button", "text primary", b === "move" ? t("Moving…") : t("Move {name} here", { name: here.name }));
mv.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: here.name });
mv.disabled = busy.size > 0;
mv.onclick = () => move(here);
val.append(mv);
}
for (const c of ask ? [] : clashes.filter((c) => c.by !== e.spec)) {
const u = el("button", "text", b === "prefer" ? t("Switching…") : t("Use for {name}", { name: c.name }));
u.title = t("{name} runs on this plugin instead of {other}; both stay installed", { name: c.name, other: nameOfSpec(c.by) });
u.disabled = busy.size > 0;
u.onclick = () => act(pkg, "prefer", { spec: e.spec, provider: c.id }, () => status(t("{name} now runs on {plugin}", { name: c.name, plugin: shownName(e) }), "ok"));
val.append(u);
}
if (!e.off && !e.error && subs.some((x) => !x.signedIn)) {
// signed in to one of its subscriptions already (Qoder, beside Qoder
// CN; #556): a blue Sign in read as the plugin signed out, so the
// button names the one it signs in to and stays quiet
const out = subs.find((x) => !x.signedIn), some = subs.some((x) => x.signedIn);
const s = el("button", "text" + (here || some ? "" : " primary"), some ? t("Sign in to {name}", { name: out.name }) : t("Sign in"));
s.onclick = () => pluginSignIn(out.id);
val.append(s);
}
const back = () => { if (moved.length) status(t("{names} is back on the built-in", { names }), "ok"); };
// switched on again: the built-ins that went back when it went off
// are offered its way again, in the row
const again = () => { if (movableOf(pkg).length) { asking = { pkg, op: "move" }; draw(); } };
const off = () => act(pkg, "off", { spec: e.spec, off: !e.off }, e.off ? again : back);
const remove = () => act(pkg, "remove", { spec: e.spec }, () => { status(t("{name} removed", { name: l?.name || pkg }), "ok"); back(); });
if (ask) {
// asked in the row, not a dialog: the user sees which go back
const yes = el("button", "text primary", t(ask === "move" ? "Move" : ask === "remove" ? "Remove" : "Switch off"));
yes.onclick = () => { asking = null; ask === "move" ? move(cands[0]) : ask === "remove" ? remove() : off(); };
const no = el("button", "text", t(ask === "move" ? "Not now" : "Cancel"));
no.onclick = () => { asking = null; draw(); };
val.append(no, yes);
r.append(val);
return r;
}
if (mw && !e.off && !mw.error && editing?.pkg !== pkg) {
const o = el("button", "text", t("Options"));
o.title = e.options ? t("The options it runs with") : t("It runs with no options; its package suggests some");
o.disabled = !!b;
o.onclick = () => {
editing = { pkg, text: JSON.stringify(e.options || e.optionsExample || {}, null, 2), error: "" };
draw();
// the field the click opened takes the keys, where it is
page.querySelector(".pm-opts textarea")?.focus({ preventScroll: true });
};
val.append(o);
}
const onoff = el("button", "text", b === "off" && !e.off && moved.length ? t("Moving back…") : t(e.off ? "Switch on" : "Switch off"));
onoff.disabled = !!b;
onoff.onclick = () => { if (!e.off && moved.length) { asking = { pkg, op: "off" }; draw(); } else off(); };
const rm = el("button", "text quiet", b === "remove" ? t(moved.length ? "Moving back…" : "Removing…") : t("Remove"));
rm.title = moved.length ? t("{names} goes back to the built-in first, then the plugin is removed", { names }) : t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
rm.disabled = busy.size > 0;
rm.onclick = async () => {
if (moved.length) {
asking = { pkg, op: "remove" };
draw();
return;
}
if (await confirmRemoval(l?.name || pkg, rm.title)) remove();
};
val.append(onoff, rm);
r.append(val);
r.onclick = (ev) => { if (!ev.target.closest("button, .pm-opts")) detail(l || (isGit(e.spec) ? { package: e.spec, name: e.package || e.spec, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: label(e.spec), npm: { version: e.latest } })); };
return r;
}
// optionsEditor is a middleware's options as JSON, in its row: what it
// runs with, or what its package suggests when it has none. The draft is
// kept in editing, so a redraw while typing doesn't lose it.
function optionsEditor(e) {
const box = el("div", "pm-opts");
const ta = document.createElement("textarea");
ta.setAttribute("aria-label", t("Options"));
ta.setAttribute("aria-describedby", "pluginOptionsNote pluginOptionsError");
ta.setAttribute("aria-invalid", String(!!editing.error));
ta.spellcheck = false;
ta.value = editing.text;
ta.rows = Math.min(14, Math.max(4, editing.text.split("\n").length + 1));
ta.oninput = () => { editing.text = ta.value; };
ta.onkeydown = (ev) => {
if (ev.key === "Escape") { editing = null; draw(); }
if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); save(); }
};
const err = el("div", "pm-opts-err", editing.error);
err.id = "pluginOptionsError";
err.setAttribute("role", "alert");
const bar = el("div", "pm-opts-bar");
const note = el("span", "", e.options ? t("Applied to the next request") : t("Not set: these are its package's example"));
note.id = "pluginOptionsNote";
const save = () => {
let v;
try { v = JSON.parse(editing.text || "{}"); } catch (x) { editing.error = t("Not JSON: {error}", { error: x.message }); return draw(); }
if (!v || typeof v !== "object" || Array.isArray(v)) { editing.error = t("Options are a JSON object"); return draw(); }
editing = null;
act(name(e.spec), "options", { spec: e.spec, options: v }, () => status(t("{name}'s options saved", { name: shownName(e) }), "ok"));
};
const ok = el("button", "text primary", t("Save"));
ok.onclick = save;
const no = el("button", "text", t("Cancel"));
no.onclick = () => { editing = null; draw(); };
bar.append(note);
if (e.options) {
const clr = el("button", "text quiet", t("Clear"));
clr.title = t("Runs it with no options");
clr.onclick = async () => {
if (!await confirmAction(t("Clear"), t("Saved middleware options will be removed. The plugin will run with no options."), t("Clear"))) return;
editing = null;
act(name(e.spec), "options", { spec: e.spec, options: null }, () => status(t("{name}'s options cleared", { name: shownName(e) }), "ok"));
};
bar.append(clr);
}
bar.append(no, ok);
box.append(ta, err, bar);
return box;
}
// mwLine is a middleware's line in its plugin's row: its hooks, how many
// calls and how long each took, and the calls that failed (which went on
// as if it weren't there), or why it didn't load
// clashLine is one provider two installed plugins both sign in to, as
// the row of either says it: served here, or served by the other one
function clashLine(e, c, nameOfSpec) {
const d = el("div", "sub pm-clash" + (c.by === e.spec ? "" : " warn"));
const others = (c.by === e.spec ? c.with : [c.by]).map(nameOfSpec).join(t(", "));
d.append(el("span", "dot"), el("span", "", c.by === e.spec
? t("{other} signs in to {name} too; this one serves it", { name: c.name, other: others })
: t("{name} is served by {other}, which signs in to it too", { name: c.name, other: others })));
d.title = c.by === e.spec
? t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it", { name: c.name })
: t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want", { name: c.name });
return d;
}
function mwLine(m) {
const d = el("div", "sub pm-mw");
if (m.error) {
d.textContent = t("Middleware didn't load: {error}", { error: m.error });
d.classList.add("bad");
d.title = m.error;
return d;
}
const lang = document.documentElement.lang || undefined;
let s = t("Gateway middleware: {hooks}", { hooks: m.hooks.join(t(", ")) });
if (m.calls) s += " · " + t("{n} calls, {us} µs each", { n: m.calls.toLocaleString(lang), us: m.avgMicros < 10 ? m.avgMicros.toFixed(1) : Math.round(m.avgMicros).toLocaleString(lang) });
d.append(el("span", "", s));
if (m.events?.length) d.title = t("onEvent sees only {events} events", { events: m.events.join(t(", ")) });
if (m.failures) {
const f = el("span", "pm-mw-fail", t("{n} failed", { n: m.failures.toLocaleString(lang) }));
f.title = t("A failed call leaves the request or reply as it was") + (m.lastError ? "\n" + t("Last: {error}", { error: m.lastError }) : "");
d.append(el("span", "sep", " · "), f);
}
return d;
}
// the plugin's page: what it is, what npm says of it, and its README
async function detail(l) {
// a folder plugin: what it is comes from the folder, not from npm; a
// git one's from the folder it was installed to
const local = isPath(l.package) || isGit(l.package);
const ed = el("div", "editor pm-detail");
const hd = el("div", "ehead pm-dhead");
hd.append(logo(l.icon, true, l.kind === "middleware"));
const who = el("div", "pm-who");
const nm = el("div", "pm-name");
nm.append(el("b", "", l.name));
const by = el("div", "pm-by"); // the words in a span of their own, or a flex row cuts them without the ellipsis
by.append(el("span", "", l.community ? t("magpie community") + " · " + l.package : [l.npm?.publisher, l.package].filter(Boolean).join(" · ")));
who.append(nm, by);
const actBox = el("div", "pm-dact");
// redrawn only when what it would say changes: a button made afresh
// loses the pointer's hover
let said = "";
const redrawAct = () => {
const b = actionFor(l.package, l.name, l.npm ? l : null);
const now = b.className + b.textContent + b.disabled;
if (now !== said) { said = now; actBox.replaceChildren(b); }
};
redrawAct();
hd.append(who, el("span", "grow"), actBox);
ed.append(hd);
const main = el("div", "pm-dbody");
const sum = summary(l);
if (sum) main.append(el("p", "pm-dsum", sum));
const facts = el("div", "pm-facts");
const fact = (k, v) => { if (!v) return; const f = el("div", "pm-fact"); f.append(el("span", "k", k), typeof v === "string" ? el("span", "v", v) : v); facts.append(f); };
fact(t("Version"), l.npm?.version && "v" + l.npm.version);
fact(t("Downloads"), l.npm?.weekly ? t("{n}/week", { n: count(l.npm.weekly) }) : "");
if (l.github) fact(t("Stars"), count(l.github.stars));
fact(t("License"), l.npm?.license);
const upd = el("span", "v", "…");
fact(t("Updated"), upd);
if (l.providers?.length) fact(t("Signs in to"), l.providers.join(t(", ")));
main.append(facts);
const links = el("div", "pm-links");
const link = (label, href) => {
if (!href) return;
const a = el("a", "pm-link");
a.href = href;
a.append(el("span", "", label), glyph(OUTL, 11, 1.5));
a.onclick = (ev) => { ev.preventDefault(); api("open", { url: href }).catch(() => {}); };
links.append(a);
};
// npm is where a package's page is; a folder on this computer has none
if (!local) link("npm", "https://www.npmjs.com/package/" + l.package);
link(l.github ? "GitHub" : t("Source"), l.npm?.repository);
if (l.npm?.homepage && l.npm.homepage !== l.npm.repository && !l.npm.homepage.startsWith(l.npm.repository + "#")) link(t("Homepage"), l.npm.homepage);
main.append(links);
if (l.github) {
const w = el("p", "pm-note warn");
// from npm when its author published it there from the repository
const from = isGit(l.package)
? "Unofficial: {owner} tagged it {topic} on GitHub, and nobody at magpie has reviewed it. It is installed from the repository as it stands, and runs with your sign-in or your requests."
: "Unofficial: {owner} tagged it {topic} on GitHub, and nobody at magpie has reviewed it. It is installed from npm, as its author published it from the repository, and runs with your sign-in or your requests.";
w.append(glyph(SHIELD, 12, 1.5), el("span", "", t(from, { owner: l.github.owner, topic })));
main.append(w);
}
if (l.replaces) main.append(el("p", "pm-note", t("magpie also signs in to this itself, for now; the plugin keeps it working if the built-in one is retired.")));
const readme = el("div", "pm-readme");
readme.append(el("div", "pm-rskel"), el("div", "pm-rskel short"), el("div", "pm-rskel"));
main.append(readme);
ed.append(main);
const bar = el("div", "bar");
bar.append(el("span", "note", t("Plugins are other people's code: they sign in and make the requests, or see them as middleware.")), el("span", "grow"));
const close = el("button", "text", t("Close"));
close.onclick = () => { stop(); closeModal(); };
bar.append(close);
ed.append(bar);
openModal(ed);
// the button follows an install or a sign-in made while it's open
const tick = setInterval(() => { if (!ed.isConnected) return stop(); redrawAct(); }, 600);
function stop() { clearInterval(tick); }
try {
const p = await api("plugins/page?name=" + encodeURIComponent(l.package));
const at = l.github && !entryOf(l.package) ? l.github.pushed : p.updated;
upd.textContent = at && !at.startsWith("0001") ? new Date(at).toLocaleDateString(document.documentElement.lang || undefined, { year: "numeric", month: "short", day: "numeric" }) : "—";
readme.replaceChildren(markdown(p.readme || t("No README")));
} catch (e) {
upd.textContent = "—";
readme.replaceChildren(el("p", "pm-note", t(l.github && isGit(l.package) && !entryOf(l.package) ? "GitHub didn't answer: {error}" : local ? "Couldn't read the folder's README: {error}" : "npm didn't answer: {error}", { error: e.message })));
}
}
// markdown: a README as text, links and code — no HTML of its own, no
// pictures (a README's badges and screenshots come from anywhere)
function markdown(src) {
const root = el("div", "pm-md");
const lines = src.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
.replace(/\[\s*!\[[^\]]*\]\([^)]*\)\s*\]\([^)]*\)/g, "") // a badge: a picture in a link
.split("\n");
// a link: to the web, opened outside; to a heading of the README
// ("#-quick-start"), there, as the reader asked
const link = (href, text, into) => {
const a = el("a", "", "");
a.href = href;
inline(text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").trim() || href, a);
a.onclick = (ev) => {
ev.preventDefault();
if (href[0] !== "#") return api("open", { url: href }).catch(() => {});
const to = root.querySelector(`[data-slug="${CSS.escape(decodeURIComponent(href.slice(1)).toLowerCase())}"]`);
if (to && window.scrollOnPurpose?.(ev)) to.scrollIntoView({ block: "start", behavior: "smooth" });
};
into.append(a);
};
const inline = (text, into) => {
const re = /(`[^`]+`)|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*)\*|!\[[^\]]*\]\([^)]*\)|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|<(https?:\/\/[^>\s]+)>|<a\s[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>|<\/?[a-zA-Z][^>]*>/g;
let at = 0, m;
while ((m = re.exec(text))) {
if (m.index > at) into.append(text.slice(at, m.index));
if (m[1]) into.append(el("code", "", m[1].slice(1, -1)));
else if (m[2] || m[3]) { const b = el("strong"); inline(m[2] || m[3], b); into.append(b); }
else if (m[4]) { const e = el("em"); inline(m[4], e); into.append(e); }
else if (m[5] || m[8]) {
const href = m[6] || m[8], text = m[5] || m[9].replace(/<[^>]*>/g, "");
if (/^https?:\/\//.test(href) || /^#./.test(href)) link(href, text, into);
else inline(text, into);
} else if (m[7]) link(m[7], m[7], into);
at = re.lastIndex;
}
if (at < text.length) into.append(text.slice(at));
};
// GitHub's anchor for a heading: lower case, its punctuation and
// emoji gone, spaces as dashes ("💡 Philosophy" is "-philosophy")
const slug = (s) => s.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, "").replace(/\s/g, "-");
let i = 0, list = null, para = null;
const flush = () => { list = null; para = null; };
while (i < lines.length) {
const line = lines[i];
const fence = line.match(/^\s*(```|~~~)\s*([\w+-]*)/);
if (fence) {
flush();
const code = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++]);
i++;
const pre = el("pre", "", "");
pre.append(el("code", "", code.join("\n")));
root.append(pre);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
if (h) {
flush();
const e = el("h" + Math.min(6, h[1].length + 2));
inline(h[2], e);
e.dataset.slug = slug(e.textContent);
if (e.textContent.trim()) root.append(e);
i++;
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] || "")) {
flush();
const cells = (s) => s.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const wrap = el("div", "pm-table");
const table = el("table");
const tr = el("tr");
for (const c of cells(line)) { const th = el("th"); inline(c, th); tr.append(th); }
table.append(tr);
i += 2;
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
const r = el("tr");
for (const c of cells(lines[i])) { const td = el("td"); inline(c, td); r.append(td); }
table.append(r);
i++;
}
wrap.append(table);
root.append(wrap);
continue;
}
const li = line.match(/^\s*([-*+]|\d+[.)])\s+(.*)$/);
if (li) {
para = null;
const ordered = /\d/.test(li[1]);
if (!list || list.tagName !== (ordered ? "OL" : "UL")) { list = el(ordered ? "ol" : "ul"); root.append(list); }
const item = el("li");
inline(li[2].replace(/^\[( |x)\]\s*/i, (s, x) => x.trim() ? "✓ " : "☐ "), item);
list.append(item);
i++;
continue;
}
const q = line.match(/^\s*>\s?(.*)$/);
if (q) {
flush();
const bq = el("blockquote");
const parts = [];
while (i < lines.length && /^\s*>/.test(lines[i])) parts.push(lines[i++].replace(/^\s*>\s?/, ""));
inline(parts.join(" ").replace(/^\[!(\w+)\]\s*/, (s, k) => k.toUpperCase() + ": "), bq);
root.append(bq);
continue;
}
if (/^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(line)) { flush(); root.append(el("hr")); i++; continue; }
if (!line.trim()) { flush(); i++; continue; }
if (list && /^\s{2,}\S/.test(line)) { const last = list.lastElementChild; last.append(" "); inline(line.trim(), last); i++; continue; }
if (!para) { para = el("p"); root.append(para); list = null; } else para.append(" ");
inline(line.trim(), para);
if (!para.textContent.trim() && !para.children.length) { para.remove(); para = null; }
i++;
}
for (const p of root.querySelectorAll("p")) if (!p.textContent.trim()) p.remove();
return root;
}
// opened on the Plugins tab (?view=plugins): app.js showed it before
// this file was here to load it
if (view === "plugins") load();
})();