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.
This commit is contained in:
Guillaume
2026-10-19 15:42:13 +02:00
committed by Rūdolfs Ošiņš
parent 44c4c37fc7
commit 783fa6be64
5 changed files with 106 additions and 0 deletions
+2
View File
@@ -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.
+6
View File
@@ -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);
+29
View File
@@ -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" });
+15
View File
@@ -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
+54
View File
@@ -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 = '<img alt="logo" src="logo.png">';
utils.untwemoji(node);
expect(node.innerHTML).toEqual('<img alt="logo" src="logo.png">');
});
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 = "<img src=x onerror=alert(1)>";
node.append(img);
utils.untwemoji(node);
expect(node.childElementCount).toEqual(0);
expect(node.textContent).toEqual("<img src=x onerror=alert(1)>");
});
test("drops emoji images without alt text", () => {
const node = document.createElement("code");
node.innerHTML = 'a<img class="txt-emoji" src="/twemoji/274c.svg">b';
utils.untwemoji(node);
expect(node.innerHTML).toEqual("ab");
});
});