diff --git a/src/components/Markdown.svelte b/src/components/Markdown.svelte index d343e34ba..05ceeacde 100644 --- a/src/components/Markdown.svelte +++ b/src/components/Markdown.svelte @@ -35,6 +35,7 @@ import { isUrl, twemoji, + untwemoji, scrollIntoView, canonicalize, canonicalizeGithubImageUrl, @@ -248,6 +249,7 @@ for (const node of nodes) { const preElement = node.parentElement as HTMLElement; const copyButton = document.createElement("radicle-clipboard"); + untwemoji(node); copyButton.setAttribute("text", node.textContent || ""); // Create a wrapper around the pre element, // so we can position the copy button that works even when scrolling horizontally. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 371467110..11c62c78c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -315,6 +315,12 @@ export function twemoji( }); } +export function untwemoji(node: Element) { + for (const img of node.querySelectorAll("img.txt-emoji")) { + img.replaceWith(img.getAttribute("alt") ?? ""); + } +} + // Formats COBs Object Ids export function formatObjectId(id: string): string { return id.substring(0, 7); diff --git a/tests/e2e/repo.spec.ts b/tests/e2e/repo.spec.ts index d802b7260..9d8083f69 100644 --- a/tests/e2e/repo.spec.ts +++ b/tests/e2e/repo.spec.ts @@ -567,6 +567,35 @@ test.describe("browser error handling", () => { }); }); +test("emoji in markdown code blocks", async ({ page }) => { + await page.goto(`${markdownUrl}/tree/main/markdown-code.md`); + + const highlighted = page + .locator("div.pre-wrapper") + .filter({ hasText: "alert" }); + const highlightedText = + 'alert("test"); // ❌\nconfirm("test"); // ❌✅\nprompt("test"); // 👩‍💻\n'; + await expect(highlighted.locator("code .pl-c")).toHaveText([ + "// ❌", + "// ❌✅", + "// 👩‍💻", + ]); + await expect(highlighted.locator("code")).toHaveText(highlightedText); + await expect(highlighted.locator("img")).toHaveCount(0); + await expect(highlighted.locator("radicle-clipboard")).toHaveAttribute( + "text", + highlightedText, + ); + + const plain = page.locator("div.pre-wrapper").filter({ hasText: "plain:" }); + await expect(plain.locator("code")).toHaveText("plain: ❌ ✅\n"); + await expect(plain.locator("img")).toHaveCount(0); + await expect(plain.locator("radicle-clipboard")).toHaveAttribute( + "text", + "plain: ❌ ✅\n", + ); +}); + test("external markdown link", async ({ context, page }) => { await context.route("https://example.com/**", route => { return route.fulfill({ body: "hello", contentType: "text/plain" }); diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts index eba07aa3a..feeaceb29 100644 --- a/tests/support/fixtures.ts +++ b/tests/support/fixtures.ts @@ -559,6 +559,21 @@ export async function createMarkdownFixture(peer: RadiclePeer) { await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, { recursive: true, }); + await Fs.writeFile( + Path.join(repoFolder, "markdown-code.md"), + [ + "```javascript", + 'alert("test"); // ❌', + 'confirm("test"); // ❌✅', + 'prompt("test"); // 👩‍💻', + "```", + "", + "```", + "plain: ❌ ✅", + "```", + "", + ].join("\n"), + ); await peer.git(["add", "."], { cwd: repoFolder }); const commitMessage = `Add Markdown cheat sheet diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index ae90db125..4d57491a9 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -375,3 +375,57 @@ describe("safeDecodeURIComponent", () => { ); }); }); + +describe("untwemoji", () => { + function code(text: string) { + const node = document.createElement("code"); + node.textContent = text; + utils.twemoji(node); + return node; + } + + test("restores emoji turned into images by twemoji", () => { + const text = + 'alert("test"); // ❌\nconfirm("test"); // ❌\nprompt("test"); // ❌\n'; + const node = code(text); + expect(node.querySelectorAll("img.txt-emoji")).toHaveLength(3); + + utils.untwemoji(node); + expect(node.querySelector("img")).toBeNull(); + expect(node.textContent).toEqual(text); + }); + + test("restores adjacent and multi-codepoint emoji", () => { + const text = "❌❌✅ 👩‍💻 👍🏽 🇱🇻"; + const node = code(text); + utils.untwemoji(node); + expect(node.querySelector("img")).toBeNull(); + expect(node.textContent).toEqual(text); + }); + + test("leaves images that are not emoji alone", () => { + const node = document.createElement("code"); + node.innerHTML = 'logo'; + utils.untwemoji(node); + expect(node.innerHTML).toEqual('logo'); + }); + + test("inserts alt text as text, never as markup", () => { + const node = document.createElement("code"); + const img = document.createElement("img"); + img.className = "txt-emoji"; + img.alt = ""; + node.append(img); + + utils.untwemoji(node); + expect(node.childElementCount).toEqual(0); + expect(node.textContent).toEqual(""); + }); + + test("drops emoji images without alt text", () => { + const node = document.createElement("code"); + node.innerHTML = 'ab'; + utils.untwemoji(node); + expect(node.innerHTML).toEqual("ab"); + }); +});