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, '');