diff --git a/internal/gui/assets/plugins.css b/internal/gui/assets/plugins.css index a2e11b60..ebfb7919 100644 --- a/internal/gui/assets/plugins.css +++ b/internal/gui/assets/plugins.css @@ -159,7 +159,7 @@ .pm-md a:hover { text-decoration: underline; } .pm-md code { font: 11.5px var(--code); padding: 1px 4px; border-radius: 4px; background: var(--pill); color: var(--fg); } .pm-md pre { margin: 0 0 10px; padding: 9px 11px; border-radius: 8px; background: var(--card-2); border: 1px solid var(--line-2); overflow-x: auto; } -.pm-md pre code { padding: 0; background: none; font-size: 11.5px; line-height: 1.5; white-space: pre; } +.pm-md pre code { padding: 0; background: none; font-size: 11.5px; line-height: 1.35; white-space: pre; } /* close enough for a box drawn in │ and ─ to join */ .pm-md blockquote { margin: 0 0 9px; padding: 7px 11px; border-radius: 8px; background: var(--card-2); color: var(--fg-2); } .pm-md hr { border: 0; border-top: 1px solid var(--line-2); margin: 12px 0; } .pm-table { overflow-x: auto; margin: 0 0 10px; } diff --git a/internal/gui/assets/plugins.js b/internal/gui/assets/plugins.js index be79e79d..e0e99d0f 100644 --- a/internal/gui/assets/plugins.js +++ b/internal/gui/assets/plugins.js @@ -446,7 +446,9 @@ 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 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 @@ -513,34 +515,43 @@ // 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, "").split("\n"); + 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]+)>|<\/?[a-zA-Z][^>]*>/g; + 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]) 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); - } + 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) { @@ -562,6 +573,7 @@ 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; diff --git a/internal/gui/tests/plugin-market.test.cjs b/internal/gui/tests/plugin-market.test.cjs index c089416a..a66b893c 100644 --- a/internal/gui/tests/plugin-market.test.cjs +++ b/internal/gui/tests/plugin-market.test.cjs @@ -31,8 +31,13 @@ const listings = [ const README = [ "# opencode-copilot-auth", "![badge](https://img.shields.io/npm/v/x.svg) Copilot for **OpenCode**.", + "**Curated by [Ann](https://example.test/ann)**", + "[![npm version](https://img.shields.io/npm/v/x.svg)](https://www.npmjs.com/package/x)", + "[![Tests](https://github.com/x/y/badge.svg)](https://github.com/x/y/actions)", + "[Jump](#-install) · HTML link", + ...Array.from({ length: 40 }, (_, i) => "\nFiller paragraph " + i + " so the page scrolls."), "", - "## Install", + "## 🚀 Install", "```sh", "opencode auth login", "```", @@ -151,6 +156,20 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.doesNotMatch(await dlg.locator(".pm-md").innerText(), /pwned/); assert.equal(await dlg.locator(".pm-md pre code").innerText(), "opencode auth login"); await shot(page.locator("#modal .dialog"), `plugins-page-${engine}-${lang}`); + // markdown in bold is still markdown; a badge (a picture in a link) goes whole; nothing shows as source + assert.equal((await dlg.locator(".pm-md strong a").innerText()).trim(), "Ann"); + assert.doesNotMatch(await dlg.locator(".pm-md").innerText(), /!\[|\]\(|npm version|Tests|<\/?[ab]\b/); + assert.equal((await dlg.locator(".pm-md a", { hasText: "HTML link" }).innerText()).trim(), "HTML link"); + // a link to a heading goes to it, in the page, not out + const body = dlg.locator(".ebody"); + assert.equal(await body.evaluate((b) => b.scrollTop), 0); + await dlg.locator(".pm-md a", { hasText: "Jump" }).click(); + await page.waitForFunction(() => { + const b = document.querySelector("#modal .pm-detail .ebody"), h = b.querySelector("h4[data-slug='-install']"); + const r = h?.getBoundingClientRect(), v = b.getBoundingClientRect(); + return h && b.scrollTop > 0 && r.top >= v.top - 1 && r.bottom <= v.bottom + 1; + }); + assert(!asked.some(([k]) => k === "open"), "a heading's link isn't opened outside"); await dlg.locator(".pm-md a", { hasText: "the docs" }).click(); assert.deepEqual(asked.find(([k]) => k === "open")[1], { url: "https://example.test/docs" });