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 = '
';
+ utils.untwemoji(node);
+ expect(node.innerHTML).toEqual('
');
+ });
+
+ 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 = 'a
b';
+ utils.untwemoji(node);
+ expect(node.innerHTML).toEqual("ab");
+ });
+});