Files
magpie/internal/gui/assets/plugins.js
T
yetone 9ec237d4e6 gui: a Plugins tab, a market to find and install provider plugins in (在产品里要有插件市场功能,整个插件市场要美观易用), and a way to it from the providers list (供应商列表里有个精致优雅的按钮) — Discover lists the magpie community's plugins, then OpenCode's community's by weekly downloads, each card with its logo, publisher, summary, downloads, version and one button that takes it all the way: Install (Bun fetched first if need be), Sign in, Signed in; a package not on npm yet says Coming soon. Typing searches the list and, after a pause, npm, with the field keeping its focus. A card opens the plugin's page: version, downloads, licence, updated, what it signs in to, npm/source/homepage links, and its README (no images, HTML or scripts; only http(s) links, opened outside). Installed lists each plugin with its providers' sign-ins, a newer version to update to, Update all, switch off and remove; Settings' Plugins section moves here. The list comes from registry.json in magpie-community/plugins, kept for 6 hours, falling back to the copy fetched last and then the one built in (MAGPIE_PLUGIN_MARKET overrides, "off" never asks). Under Add provider a "More subscriptions in Plugins" pill opens it; the add sheet ends in a Plugins tile, and a search that finds nothing offers to look for a plugin. The header, seven tabs now, draws them in at the narrowest window. plugin-market.test.cjs (Chromium, WebKit, en/zh) and market_test.go fail without the change; add-button, plugin-signin, usage-ledger, text-size, old-webkit, click-scroll pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
2026-09-30 17:57:46 +08:00

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;
}
})();