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.
1242 lines
61 KiB
JavaScript
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();
|
|
})();
|