From 783fa6be6434d3f64686a2306b9ca202f37fe017 Mon Sep 17 00:00:00 2001 From: Guillaume Date: Wed, 19 Aug 2026 21:20:53 +0200 Subject: [PATCH] Fix emoji in markdown code blocks Emoji in code blocks were missing from both the rendered code and the copied text. twemoji replaces them with images, but highlighting and the copy button work from the block's text content, which leaves images out. We now turn the images back into text first. --- src/components/Markdown.svelte | 2 ++ src/lib/utils.ts | 6 ++++ tests/e2e/repo.spec.ts | 29 ++++++++++++++++++ tests/support/fixtures.ts | 15 ++++++++++ tests/unit/utils.test.ts | 54 ++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+) 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"); + }); +});