621 lines
26 KiB
JavaScript
621 lines
26 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. 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 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 name = (spec) => { const i = spec.lastIndexOf("@"); return i > 0 && !spec.startsWith(".") && !spec.includes("/", i) ? spec.slice(0, i) : spec; };
|
|
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.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();
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
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") {
|
|
b.classList.add("busy");
|
|
b.append(el("span", "spin"), el("span", "", 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;
|
|
}
|
|
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);
|
|
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);
|
|
top.append(logo(l.icon), who, actionFor(l.package, l.name, l));
|
|
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);
|
|
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());
|
|
const ours = ls.filter((l) => l.community);
|
|
const theirs = ls.filter((l) => !l.community).sort((a, b) => (b.npm?.weekly || 0) - (a.npm?.weekly || 0));
|
|
if (ours.length) body.append(section(t("magpie community"), t("written for magpie, checked against its own sign-ins"), ours));
|
|
if (theirs.length) body.append(section(t("From OpenCode's community"), t("popular OpenCode plugins that work in magpie"), theirs));
|
|
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 or a folder on this computer");
|
|
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, go);
|
|
return box;
|
|
}
|
|
|
|
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;
|
|
}
|
|
const list = el("div", "list pm-list");
|
|
for (const e of es) list.append(installedRow(e));
|
|
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);
|
|
}
|
|
|
|
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", "", l?.name || pkg));
|
|
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 })));
|
|
who.append(nm);
|
|
const subs = subsOf(pkg);
|
|
const sub = el("div", "sub");
|
|
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" : ""));
|
|
s.append(el("span", "dot"), el("span", "", x.signedIn ? t("{name}: signed in", { name: x.name }) : t("{name}: not signed in", { 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);
|
|
}
|
|
if (!e.off && !e.error && subs.some((x) => !x.signedIn)) {
|
|
const s = el("button", "text primary", t("Sign in"));
|
|
s.onclick = () => pluginSignIn(subs.find((x) => !x.signedIn).id);
|
|
val.append(s);
|
|
}
|
|
const onoff = el("button", "text", t(e.off ? "Switch on" : "Switch off"));
|
|
onoff.disabled = !!b;
|
|
onoff.onclick = () => act(pkg, "off", { spec: e.spec, off: !e.off });
|
|
const rm = el("button", "text quiet", b === "remove" ? t("Removing…") : t("Remove"));
|
|
rm.title = t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
|
|
rm.disabled = busy.size > 0;
|
|
rm.onclick = () => act(pkg, "remove", { spec: e.spec }, () => status(t("{name} removed", { name: l?.name || pkg }), "ok"));
|
|
val.append(onoff, rm);
|
|
r.append(val);
|
|
r.onclick = (ev) => { if (!ev.target.closest("button")) detail(l || { package: pkg, name: pkg, 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) {
|
|
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));
|
|
who.append(nm, el("div", "pm-by", l.community ? t("magpie community") + " · " + l.package : [l.npm?.publisher, l.package].filter(Boolean).join(" · ")));
|
|
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);
|
|
};
|
|
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("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, "").split("\n");
|
|
const inline = (text, into) => {
|
|
const re = /(`[^`]+`)|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|!\[[^\]]*\]\([^)]*\)|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|<(https?:\/\/[^>\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]) into.append(el("strong", "", m[2] || m[3]));
|
|
else if (m[4]) into.append(el("em", "", m[4]));
|
|
else if (m[5]) {
|
|
const href = m[6];
|
|
if (/^https?:\/\//.test(href)) {
|
|
const a = el("a", "", "");
|
|
a.href = href;
|
|
inline(m[5].replace(/!\[[^\]]*\]\([^)]*\)/g, "").trim() || href, a);
|
|
a.onclick = (ev) => { ev.preventDefault(); api("open", { url: href }).catch(() => {}); };
|
|
into.append(a);
|
|
} else inline(m[5], into);
|
|
} else if (m[7]) {
|
|
const a = el("a", "", m[7]);
|
|
a.href = m[7];
|
|
a.onclick = (ev) => { ev.preventDefault(); api("open", { url: m[7] }).catch(() => {}); };
|
|
into.append(a);
|
|
}
|
|
at = re.lastIndex;
|
|
}
|
|
if (at < text.length) into.append(text.slice(at));
|
|
};
|
|
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);
|
|
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;
|
|
}
|
|
})();
|