// 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. Everything comes from // /api/plugins/market; a sign-in happens in the Providers add sheet, as // every other subscription's does. (() => { const page = $("#view-plugins"); if (!page) return; let market = null; // /api/plugins/market let failed = ""; // why the market couldn't be loaded 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 asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes 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 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 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 || "").startsWith("zh") ? "zh" : "en"; 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); const entryOf = (pkg) => market?.state.plugins.find((e) => name(e.spec) === pkg); // the providers a plugin signs in to, as the add sheet knows them const subsOf = (pkg) => (providers?.plugins || []).filter((x) => name(x.spec) === pkg); const newer = (a, b) => { const p = (v) => (v || "").split(/[.+-]/).slice(0, 3).map((x) => parseInt(x, 10) || 0); const [x, y] = [p(a), p(b)]; for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }; function glyph(d, size = 14, stroke = 1.5) { return svg(d, size, stroke); } function logo(ic, big) { const box = el("span", "pm-logo" + (big ? " big" : "")); if (ic) box.append(icon(ic)); else box.append(glyph(PUZZLE, big ? 22 : 17, 1.4)); return box; } async function load() { const first = !market; if (first) draw(); try { const [m] = await Promise.all([api("plugins/market"), providers ? null : loadProviders().catch(() => {})]); market = m; failed = ""; } catch (e) { failed = e.message; } draw(); window.renderPluginDot?.(); } 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/market"), api("providers")]); market = m; providers = p; // 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?.(); } 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"); } } // the built-ins with accounts this plugin could run in their place const movableOf = (pkg) => (market?.state.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) => { b.classList.add("go", "move"); b.textContent = 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 }); b.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: 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…") : market?.state.bun ? t("Installing…") : t("Getting Bun…"))); b.disabled = true; if (!market?.state.bun) b.title = t("Plugins run on Bun {v}, downloaded once", { v: market?.state.bunVersion || "" }); return b; } if (!e) { if (listed && !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); if (out) { 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 (subs.length) { b.classList.add("done"); b.append(glyph(CHECK, 11, 2), el("span", "", t("Signed in"))); b.title = t("Open {name} in Providers", { name: subs[0].name }); b.onclick = (ev) => { ev.stopPropagation(); openProvider(subs[0].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); const act = actionFor(l.package, l.name, l); // a move says whose accounts: too long for the top row, it has one of its own const own = act.classList.contains("move"); top.append(logo(l.icon), who); if (!own) top.append(act); const sum = el("p", "pm-sum", summary(l)); const meta = el("div", "pm-meta"); 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)) meta.append(el("span", "pm-chip up", t("Update"))); 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); if (own) c.append(act); c.onclick = () => detail(l); c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } }; return c; } 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; } function head() { const h = el("div", "lib-head pm-head"); const n = market?.state.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"); const find = el("label", "pm-find"); find.append(glyph(SEARCH, 13, 1.6)); const q = el("input"); q.type = "search"; q.placeholder = t("Search plugins and npm…"); q.value = query; q.spellcheck = false; q.autocomplete = "off"; q.setAttribute("aria-label", t("Search plugins")); q.oninput = () => { query = q.value; if (tab !== "discover") { tab = "discover"; draw(); page.querySelector(".pm-find input")?.focus(); } 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(q); h.append(tabs, el("span", "grow"), find); h.classList.toggle("stuck", page.scrollTop > 0); return h; } 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; page.replaceChildren(head()); body = el("div", "lib-body pm-body"); page.append(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 (failed && !market) { body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failed }))); return; } if (tab === "installed") return drawInstalled(); if (!market) { body.append(intro(), skeleton(6)); return; } const f = query.trim().toLowerCase(); const ls = market.listings.filter((l) => !f || [l.name, l.package, summary(l), (l.providers || []).join(" "), l.npm?.publisher || ""].join(" ").toLowerCase().includes(f)); if (!f) { body.append(intro()); // magpie's community's alone: others' plugins are found by a search const ours = ls.filter((l) => l.community); if (ours.length) body.append(section(t("magpie community"), t("written for magpie, checked against its own sign-ins"), ours)); body.append(manual()); return; } if (ls.length) body.append(section(t("Suggested"), "", ls)); const known = new Set(market.listings.map((l) => l.package)); const box = el("section", "pm-sec"); const h = el("div", "pm-sechead"); h.append(el("h3", "", t("On npm")), el("span", "", t("OpenCode plugins 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, as plugins"))); 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, run on Bun."))); 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: 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 (market?.state.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 (!market) { body.append(skeleton(2)); return; } const es = market.state.plugins; if (!es.length) { const none = el("div", "pm-none"); none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Find a subscription in Discover and install it; it 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", "", market.state.bun ? t("Plugins run on Bun {v}", { v: market.state.bunVersion }) : ""), el("span", "grow")); if (outdated.length) { const up = el("button", "text", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length })); up.disabled = busy.size > 0; 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 market.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 = market.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)); if (e.latest && e.version && newer(e.latest, e.version)) nm.append(el("span", "pm-chip up", t("v{v} out", { v: e.latest }))); 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); } // 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); } who.append(nm); const subs = subsOf(pkg); const sub = el("div", "sub"); const cands = e.off ? [] : movableOf(pkg); 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 }); sub.append(s); } } else sub.textContent = e.providers.length ? t("Signs in to {names}", { names: e.providers.join(t(", ")) }) : t("Signs in to nothing magpie can use"); who.append(sub); r.append(logo(l?.icon || subs[0]?.icon), 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; 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); } if (!e.off && !e.error && subs.some((x) => !x.signedIn)) { const s = el("button", "text" + (here ? "" : " primary"), t("Sign in")); s.onclick = () => pluginSignIn(subs.find((x) => !x.signedIn).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; } 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 = () => { if (moved.length) { asking = { pkg, op: "remove" }; draw(); } else remove(); }; val.append(onoff, rm); r.append(val); r.onclick = (ev) => { if (!ev.target.closest("button")) 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; } // 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)); 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) }) : ""); 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(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.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.")), 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)); upd.textContent = p.updated && !p.updated.startsWith("0001") ? new Date(p.updated).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(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(/|<(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]+)>|]*?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(); })();