From f4f26d03315c831781a2d0b3ef9bd2086e39b37f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 11:49:57 +0000
Subject: [PATCH 1/9] Initial plan
From fb4ee72711fda0990e3afc866428995bbbec2da4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 12:00:06 +0000
Subject: [PATCH 2/9] Initial plan for fixing emoji rendering
Co-authored-by: dwjohnston <2467377+dwjohnston@users.noreply.github.com>
---
package-lock.json | 4 +--
sample_files/with_emoji.tsx | 12 +++++++
.../GithubPermalinkBase.stories.tsx | 33 +++++++++++++++++++
3 files changed, 47 insertions(+), 2 deletions(-)
create mode 100644 sample_files/with_emoji.tsx
diff --git a/package-lock.json b/package-lock.json
index 072a44e..b102eea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "react-github-permalink",
- "version": "1.10.3",
+ "version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "react-github-permalink",
- "version": "1.10.3",
+ "version": "1.11.0",
"license": "MIT",
"dependencies": {
"react-responsive": "^10.0.0",
diff --git a/sample_files/with_emoji.tsx b/sample_files/with_emoji.tsx
new file mode 100644
index 0000000..e8f6953
--- /dev/null
+++ b/sample_files/with_emoji.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+export function Example() {
+ // Some comment
+ const foo = "bar";
+
+ // This is a comment
+ // 👇 Check this out
+ const result = foo.toUpperCase();
+
+ return
{result}
;
+}
diff --git a/src/library/GithubPermalink/GithubPermalinkBase.stories.tsx b/src/library/GithubPermalink/GithubPermalinkBase.stories.tsx
index 795f525..88ed9d8 100644
--- a/src/library/GithubPermalink/GithubPermalinkBase.stories.tsx
+++ b/src/library/GithubPermalink/GithubPermalinkBase.stories.tsx
@@ -107,6 +107,39 @@ export const WithLineExclusionsRealCode: Story = {
excludeLines={[[105, 107]]}
excludeText="// snip"
+ />
+ ),
+};
+
+const codeWithEmoji = `export function ChildrenStyleOne() {
+ const [value, setValue] = React.useState(0)
+ return
+
ChildrenStyleOne
+
RenderTracker is directly rendered
+
+ {/* 👇 Here we declare the RenderTracker directly in the component */}
+
+
+}`;
+
+export const WithEmoji: Story = {
+ render: () => (
+
),
};
\ No newline at end of file
From d590009d5c4158e851f56c4ba58a6b01ea0ad49a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 12:03:51 +0000
Subject: [PATCH 3/9] Fix emoji rendering by adding emoji-capable fonts to code
blocks
Co-authored-by: dwjohnston <2467377+dwjohnston@users.noreply.github.com>
---
sample_files/with_emoji.tsx | 12 ------------
src/library/GithubPermalink/github-permalink.css | 2 ++
2 files changed, 2 insertions(+), 12 deletions(-)
delete mode 100644 sample_files/with_emoji.tsx
diff --git a/sample_files/with_emoji.tsx b/sample_files/with_emoji.tsx
deleted file mode 100644
index e8f6953..0000000
--- a/sample_files/with_emoji.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-
-export function Example() {
- // Some comment
- const foo = "bar";
-
- // This is a comment
- // 👇 Check this out
- const result = foo.toUpperCase();
-
- return {result}
;
-}
diff --git a/src/library/GithubPermalink/github-permalink.css b/src/library/GithubPermalink/github-permalink.css
index 0e6e0ec..41e6f25 100644
--- a/src/library/GithubPermalink/github-permalink.css
+++ b/src/library/GithubPermalink/github-permalink.css
@@ -98,6 +98,8 @@
&>pre {
/* react-style-highlighter is adding margin via style attribute*/
margin: 0 !important;
+ /* Ensure emojis render properly by including emoji-capable fonts */
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
pre+.hide-line-numbers {
From 7de010038e96cd1ef303c95f7fa1686f367269a5 Mon Sep 17 00:00:00 2001
From: David Johnston
Date: Tue, 7 Oct 2025 09:32:22 +1100
Subject: [PATCH 4/9] Add example story to the proper story.
---
src/library/GithubPermalink/GithubPermalink.stories.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/library/GithubPermalink/GithubPermalink.stories.tsx b/src/library/GithubPermalink/GithubPermalink.stories.tsx
index f2820aa..390fea7 100644
--- a/src/library/GithubPermalink/GithubPermalink.stories.tsx
+++ b/src/library/GithubPermalink/GithubPermalink.stories.tsx
@@ -37,6 +37,9 @@ export const DifferentLanguages: Story = {
TSX
+ TSX with Emoji
+
+
Docker file
From 98216cf5871af34bb03163e628260bbe270b302b Mon Sep 17 00:00:00 2001
From: David Johnston
Date: Tue, 7 Oct 2025 09:50:41 +1100
Subject: [PATCH 5/9] Add example fix.
---
src/library/config/defaultFunctions.ts | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts
index 63f54ad..c595c19 100644
--- a/src/library/config/defaultFunctions.ts
+++ b/src/library/config/defaultFunctions.ts
@@ -32,10 +32,10 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
issueState: issueJson.state,
status: "ok",
owner: config.owner,
- repo: config.repo,
+ repo: config.repo,
reactions: issueJson.reactions,
};
-}export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise {
+} export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise {
const config = parseGithubPermalinkUrl(permalink);
@@ -61,7 +61,7 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
}
const [contentJson, commitJson] = await Promise.all([contentResult.json(), commitResult.json()]);
- const content = atob(contentJson.content);
+ const content = decodeURIComponent(escape(atob(contentJson.content)));
const lines = content.split("\n");
return {
@@ -76,6 +76,9 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
status: "ok"
};
}
+
+
+
export function handleResponse(response: Response): ErrorResponses {
if (response.status === 404) {
return { status: "404" };
@@ -87,7 +90,7 @@ export function handleResponse(response: Response): ErrorResponses {
};
}
- if(response.status === 401) {
+ if (response.status === 401) {
return {
status: "unauthorized"
}
From 7d07c63d9cd0f0d914ded28083b699e3ed85265b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 22:58:10 +0000
Subject: [PATCH 6/9] Replace deprecated escape() with proper UTF-8 decoding
using TextDecoder
The escape/unescape pattern works but uses deprecated methods. This replaces it with the modern TextDecoder API which properly handles UTF-8 multi-byte characters like emojis.
Why the deprecated pattern worked:
- atob() decodes base64 but treats bytes as Latin-1
- escape() percent-encodes the malformed string
- decodeURIComponent() interprets percent-encoded bytes as UTF-8
Modern solution:
- atob() decodes base64 to binary string
- Convert to Uint8Array byte array
- TextDecoder properly interprets bytes as UTF-8
Added comprehensive tests verifying both patterns give identical results.
Co-authored-by: dwjohnston <2467377+dwjohnston@users.noreply.github.com>
---
src/library/config/defaultFunctions.test.ts | 108 ++++++++++++++++++++
src/library/config/defaultFunctions.ts | 30 +++++-
2 files changed, 137 insertions(+), 1 deletion(-)
create mode 100644 src/library/config/defaultFunctions.test.ts
diff --git a/src/library/config/defaultFunctions.test.ts b/src/library/config/defaultFunctions.test.ts
new file mode 100644
index 0000000..d256f30
--- /dev/null
+++ b/src/library/config/defaultFunctions.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest';
+
+/**
+ * This is a copy of the helper function from defaultFunctions.ts for testing purposes.
+ */
+function decodeBase64WithUTF8(base64: string): string {
+ const cleanedBase64 = base64.replace(/\s/g, '');
+ const binaryString = atob(cleanedBase64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ const decoder = new TextDecoder('utf-8');
+ return decoder.decode(bytes);
+}
+
+describe('Base64 UTF-8 Decoding', () => {
+ it('should correctly decode base64 with ASCII text', () => {
+ // Simple ASCII text
+ const text = 'Hello World';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const result = decodeBase64WithUTF8(base64);
+ expect(result).toBe(text);
+ });
+
+ it('should correctly decode base64 with emoji', () => {
+ // Text with emoji - the actual issue from GitHub
+ const text = '// 👇 Check this out';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const result = decodeBase64WithUTF8(base64);
+ expect(result).toBe(text);
+ expect(result).toContain('👇');
+ });
+
+ it('should correctly decode base64 with pointing up emoji', () => {
+ // The specific emoji mentioned in the user's comment
+ const text = '☝️';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const result = decodeBase64WithUTF8(base64);
+ expect(result).toBe(text);
+ });
+
+ it('should correctly decode base64 with multiple emojis', () => {
+ const text = '👍 👎 😕 ❤️ 🎉';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const result = decodeBase64WithUTF8(base64);
+ expect(result).toBe(text);
+ });
+
+ it('should correctly decode base64 with mixed content', () => {
+ const text = 'export function Example() {\n // 👇 This is a comment\n return Hello
;\n}';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const result = decodeBase64WithUTF8(base64);
+ expect(result).toBe(text);
+ expect(result).toContain('👇');
+ });
+
+ it('should handle base64 with whitespace (as GitHub API might return)', () => {
+ const text = 'Test 👍';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+ const base64WithWhitespace = base64.slice(0, 5) + '\n' + base64.slice(5);
+
+ const result = decodeBase64WithUTF8(base64WithWhitespace);
+ expect(result).toBe(text);
+ });
+
+ it('should match the deprecated escape/unescape pattern behavior', () => {
+ // Verify our solution gives the same result as the deprecated method
+ const text = '// 👇 This is a comment with emoji';
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(text);
+ const binaryString = String.fromCharCode(...bytes);
+ const base64 = btoa(binaryString);
+
+ const modernResult = decodeBase64WithUTF8(base64);
+
+ // The deprecated pattern: decodeURIComponent(escape(atob(base64)))
+ // escape() converts the incorrectly-decoded UTF-8 bytes to percent-encoding
+ // decodeURIComponent() then interprets those percent-encoded bytes as UTF-8
+ const deprecatedResult = decodeURIComponent(escape(atob(base64)));
+
+ expect(modernResult).toBe(deprecatedResult);
+ expect(modernResult).toBe(text);
+ });
+});
diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts
index c595c19..7a9eab8 100644
--- a/src/library/config/defaultFunctions.ts
+++ b/src/library/config/defaultFunctions.ts
@@ -3,6 +3,34 @@ import { parseGithubIssueLink, parseGithubPermalinkUrl } from "../utils/urlParse
import { GithubPermalinkDataResponse } from "./GithubPermalinkContext";
import { ErrorResponses } from "./GithubPermalinkContext";
+/**
+ * Properly decode base64 string with UTF-8 support.
+ * GitHub API returns base64-encoded content that may contain UTF-8 characters like emojis.
+ *
+ * The issue: atob() decodes base64 to a binary string, but treats each byte as a Latin-1 character.
+ * For UTF-8 multi-byte characters (like emojis), this corrupts the data.
+ *
+ * The solution: Convert the binary string to a byte array, then use TextDecoder to properly
+ * interpret those bytes as UTF-8.
+ */
+function decodeBase64WithUTF8(base64: string): string {
+ // Remove whitespace that GitHub API might include
+ const cleanedBase64 = base64.replace(/\s/g, '');
+
+ // Decode base64 to binary string (each character represents a byte)
+ const binaryString = atob(cleanedBase64);
+
+ // Convert binary string to byte array
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ // Decode UTF-8 bytes to string
+ const decoder = new TextDecoder('utf-8');
+ return decoder.decode(bytes);
+}
+
export async function defaultGetIssueFn(issueLink: string, githubToken?: string, onError?: (err: unknown) => void): Promise {
const config = parseGithubIssueLink(issueLink);
@@ -61,7 +89,7 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
}
const [contentJson, commitJson] = await Promise.all([contentResult.json(), commitResult.json()]);
- const content = decodeURIComponent(escape(atob(contentJson.content)));
+ const content = decodeBase64WithUTF8(contentJson.content);
const lines = content.split("\n");
return {
From 0e9e18ab043cd1fd892d913dc5bc200a20c3d7c3 Mon Sep 17 00:00:00 2001
From: David Johnston
Date: Wed, 8 Oct 2025 20:58:44 +1100
Subject: [PATCH 7/9] Add note to document AI generated code
---
src/library/config/defaultFunctions.ts | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts
index 7a9eab8..809bd7a 100644
--- a/src/library/config/defaultFunctions.ts
+++ b/src/library/config/defaultFunctions.ts
@@ -4,6 +4,9 @@ import { GithubPermalinkDataResponse } from "./GithubPermalinkContext";
import { ErrorResponses } from "./GithubPermalinkContext";
/**
+ * This is AI generated code from GitHub Copilot.
+ * See: https://github.com/dwjohnston/react-github-permalink/pull/79
+ *
* Properly decode base64 string with UTF-8 support.
* GitHub API returns base64-encoded content that may contain UTF-8 characters like emojis.
*
@@ -16,16 +19,16 @@ import { ErrorResponses } from "./GithubPermalinkContext";
function decodeBase64WithUTF8(base64: string): string {
// Remove whitespace that GitHub API might include
const cleanedBase64 = base64.replace(/\s/g, '');
-
+
// Decode base64 to binary string (each character represents a byte)
const binaryString = atob(cleanedBase64);
-
+
// Convert binary string to byte array
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
-
+
// Decode UTF-8 bytes to string
const decoder = new TextDecoder('utf-8');
return decoder.decode(bytes);
From c5c92632edd168518e84403bf0891a3f1a517f32 Mon Sep 17 00:00:00 2001
From: David Johnston
Date: Wed, 8 Oct 2025 20:59:17 +1100
Subject: [PATCH 8/9] Create sharp-pianos-hammer.md
---
.changeset/sharp-pianos-hammer.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/sharp-pianos-hammer.md
diff --git a/.changeset/sharp-pianos-hammer.md b/.changeset/sharp-pianos-hammer.md
new file mode 100644
index 0000000..2085f80
--- /dev/null
+++ b/.changeset/sharp-pianos-hammer.md
@@ -0,0 +1,5 @@
+---
+"react-github-permalink": patch
+---
+
+Fix emoji rendering in code blocks with UTF-8 decoding
From a91ced0490053112987801bc9228c8cfb4004604 Mon Sep 17 00:00:00 2001
From: David Johnston
Date: Wed, 8 Oct 2025 21:07:00 +1100
Subject: [PATCH 9/9] Update test
---
src/library/config/defaultFunctions.test.ts | 97 ++-------------------
src/library/config/defaultFunctions.ts | 4 +-
2 files changed, 12 insertions(+), 89 deletions(-)
diff --git a/src/library/config/defaultFunctions.test.ts b/src/library/config/defaultFunctions.test.ts
index d256f30..8a97d9e 100644
--- a/src/library/config/defaultFunctions.test.ts
+++ b/src/library/config/defaultFunctions.test.ts
@@ -1,107 +1,28 @@
import { describe, it, expect } from 'vitest';
-
-/**
- * This is a copy of the helper function from defaultFunctions.ts for testing purposes.
- */
-function decodeBase64WithUTF8(base64: string): string {
- const cleanedBase64 = base64.replace(/\s/g, '');
- const binaryString = atob(cleanedBase64);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- const decoder = new TextDecoder('utf-8');
- return decoder.decode(bytes);
-}
+import { decodeBase64WithUTF8 } from './defaultFunctions';
describe('Base64 UTF-8 Decoding', () => {
- it('should correctly decode base64 with ASCII text', () => {
- // Simple ASCII text
- const text = 'Hello World';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
-
- const result = decodeBase64WithUTF8(base64);
- expect(result).toBe(text);
- });
-
- it('should correctly decode base64 with emoji', () => {
- // Text with emoji - the actual issue from GitHub
- const text = '// 👇 Check this out';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
-
- const result = decodeBase64WithUTF8(base64);
- expect(result).toBe(text);
- expect(result).toContain('👇');
- });
+ it('should match the deprecated escape/unescape pattern behavior', () => {
+ const text = '// 👇 This is a comment with emoji';
- it('should correctly decode base64 with pointing up emoji', () => {
- // The specific emoji mentioned in the user's comment
- const text = '☝️';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
-
- const result = decodeBase64WithUTF8(base64);
- expect(result).toBe(text);
- });
+ // We're using the deprecated pattern for a sanity check to see that the AI generated version is correct.
+ const deprecatedEncodedString = btoa(unescape(encodeURIComponent(text)));
- it('should correctly decode base64 with multiple emojis', () => {
- const text = '👍 👎 😕 ❤️ 🎉';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
-
- const result = decodeBase64WithUTF8(base64);
- expect(result).toBe(text);
- });
-
- it('should correctly decode base64 with mixed content', () => {
- const text = 'export function Example() {\n // 👇 This is a comment\n return Hello
;\n}';
+ // Verify our solution gives the same result as the deprecated method
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
const binaryString = String.fromCharCode(...bytes);
const base64 = btoa(binaryString);
-
- const result = decodeBase64WithUTF8(base64);
- expect(result).toBe(text);
- expect(result).toContain('👇');
- });
- it('should handle base64 with whitespace (as GitHub API might return)', () => {
- const text = 'Test 👍';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
- const base64WithWhitespace = base64.slice(0, 5) + '\n' + base64.slice(5);
-
- const result = decodeBase64WithUTF8(base64WithWhitespace);
- expect(result).toBe(text);
- });
- it('should match the deprecated escape/unescape pattern behavior', () => {
- // Verify our solution gives the same result as the deprecated method
- const text = '// 👇 This is a comment with emoji';
- const encoder = new TextEncoder();
- const bytes = encoder.encode(text);
- const binaryString = String.fromCharCode(...bytes);
- const base64 = btoa(binaryString);
-
+ expect(deprecatedEncodedString).toBe(base64);
const modernResult = decodeBase64WithUTF8(base64);
-
+
// The deprecated pattern: decodeURIComponent(escape(atob(base64)))
// escape() converts the incorrectly-decoded UTF-8 bytes to percent-encoding
// decodeURIComponent() then interprets those percent-encoded bytes as UTF-8
const deprecatedResult = decodeURIComponent(escape(atob(base64)));
-
+
expect(modernResult).toBe(deprecatedResult);
expect(modernResult).toBe(text);
});
diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts
index 809bd7a..a057fed 100644
--- a/src/library/config/defaultFunctions.ts
+++ b/src/library/config/defaultFunctions.ts
@@ -6,6 +6,8 @@ import { ErrorResponses } from "./GithubPermalinkContext";
/**
* This is AI generated code from GitHub Copilot.
* See: https://github.com/dwjohnston/react-github-permalink/pull/79
+ * But based on my reading of this:https://stackoverflow.com/a/56647993/1068446
+ * But the suggested answer is using deprecated functions (escape/unescape)
*
* Properly decode base64 string with UTF-8 support.
* GitHub API returns base64-encoded content that may contain UTF-8 characters like emojis.
@@ -16,7 +18,7 @@ import { ErrorResponses } from "./GithubPermalinkContext";
* The solution: Convert the binary string to a byte array, then use TextDecoder to properly
* interpret those bytes as UTF-8.
*/
-function decodeBase64WithUTF8(base64: string): string {
+export function decodeBase64WithUTF8(base64: string): string {
// Remove whitespace that GitHub API might include
const cleanedBase64 = base64.replace(/\s/g, '');