Skip to content

Commit c717b2f

Browse files
Copilotdwjohnston
andauthored
Fix emoji rendering in code blocks with UTF-8 decoding and emoji fonts (#79)
* Initial plan * Initial plan for fixing emoji rendering Co-authored-by: dwjohnston <[email protected]> * Fix emoji rendering by adding emoji-capable fonts to code blocks Co-authored-by: dwjohnston <[email protected]> * Add example story to the proper story. * Add example fix. * 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 <[email protected]> * Add note to document AI generated code * Create sharp-pianos-hammer.md * Update test --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: dwjohnston <[email protected]> Co-authored-by: David Johnston <[email protected]>
1 parent 76b5037 commit c717b2f

File tree

7 files changed

+113
-8
lines changed

7 files changed

+113
-8
lines changed

.changeset/sharp-pianos-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-github-permalink": patch
3+
---
4+
5+
Fix emoji rendering in code blocks with UTF-8 decoding

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/library/GithubPermalink/GithubPermalink.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const DifferentLanguages: Story = {
3737
<p>TSX</p>
3838
<GithubPermalink permalink="https://github.com/dwjohnston/react-github-permalink/blob/242681a9df549adcc9a7fca0d8421d98b7e312c4/sample_files/sample1.tsx#L1-L11" />
3939

40-
<p>TSX2</p>
41-
<GithubPermalink permalink="https://github.com/dwjohnston/react-renders/blob/b91494bff90774073c10ba7a2a362d37c8d083ef/src/react-renders/ReactRenders3.tsx#L8-L32" />
40+
<p>TSX with Emoji</p>
41+
<GithubPermalink permalink=" https://github.com/dwjohnston/react-renders/blob/b91494bff90774073c10ba7a2a362d37c8d083ef/src/react-renders/ReactRenders3.tsx#L8-L19" />
4242

4343

4444
<p>Docker file</p>

src/library/GithubPermalink/GithubPermalinkBase.stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,39 @@ export const WithLineExclusionsRealCode: Story = {
107107
excludeLines={[[105, 107]]}
108108
excludeText="// snip"
109109

110+
/>
111+
),
112+
};
113+
114+
const codeWithEmoji = `export function ChildrenStyleOne() {
115+
const [value, setValue] = React.useState(0)
116+
return <div className="some-parent-component">
117+
<strong>ChildrenStyleOne</strong>
118+
<p>RenderTracker is directly rendered</p>
119+
<button onClick={() => {
120+
setValue((prev) => prev + 1);;
121+
}}>Increase count: {value}</button>
122+
{/* 👇 Here we declare the RenderTracker directly in the component */}
123+
<RenderTracker />
124+
</div>
125+
}`;
126+
127+
export const WithEmoji: Story = {
128+
render: () => (
129+
<GithubPermalinkBase
130+
permalink="https://github.com/dwjohnston/react-renders/src/react-renders/ReactRenders3.tsx#L8-L32"
131+
data={{
132+
lines: codeWithEmoji.split('\n'),
133+
lineFrom: 8,
134+
lineTo: 19,
135+
commit: "b91494b",
136+
path: "src/react-renders/ReactRenders3.tsx",
137+
owner: "dwjohnston",
138+
repo: "react-renders",
139+
commitUrl: "https://github.com/dwjohnston/react-renders/commit/b91494b",
140+
status: "ok"
141+
}}
142+
language="typescript"
110143
/>
111144
),
112145
};

src/library/GithubPermalink/github-permalink.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@
9898
&>pre {
9999
/* react-style-highlighter is adding margin via style attribute*/
100100
margin: 0 !important;
101+
/* Ensure emojis render properly by including emoji-capable fonts */
102+
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";
101103
}
102104

103105
pre+.hide-line-numbers {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { decodeBase64WithUTF8 } from './defaultFunctions';
3+
4+
describe('Base64 UTF-8 Decoding', () => {
5+
it('should match the deprecated escape/unescape pattern behavior', () => {
6+
const text = '// 👇 This is a comment with emoji';
7+
8+
// We're using the deprecated pattern for a sanity check to see that the AI generated version is correct.
9+
const deprecatedEncodedString = btoa(unescape(encodeURIComponent(text)));
10+
11+
// Verify our solution gives the same result as the deprecated method
12+
const encoder = new TextEncoder();
13+
const bytes = encoder.encode(text);
14+
const binaryString = String.fromCharCode(...bytes);
15+
const base64 = btoa(binaryString);
16+
17+
18+
expect(deprecatedEncodedString).toBe(base64);
19+
const modernResult = decodeBase64WithUTF8(base64);
20+
21+
// The deprecated pattern: decodeURIComponent(escape(atob(base64)))
22+
// escape() converts the incorrectly-decoded UTF-8 bytes to percent-encoding
23+
// decodeURIComponent() then interprets those percent-encoded bytes as UTF-8
24+
const deprecatedResult = decodeURIComponent(escape(atob(base64)));
25+
26+
expect(modernResult).toBe(deprecatedResult);
27+
expect(modernResult).toBe(text);
28+
});
29+
});

src/library/config/defaultFunctions.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,39 @@ import { parseGithubIssueLink, parseGithubPermalinkUrl } from "../utils/urlParse
33
import { GithubPermalinkDataResponse } from "./GithubPermalinkContext";
44
import { ErrorResponses } from "./GithubPermalinkContext";
55

6+
/**
7+
* This is AI generated code from GitHub Copilot.
8+
* See: https://github.com/dwjohnston/react-github-permalink/pull/79
9+
* But based on my reading of this:https://stackoverflow.com/a/56647993/1068446
10+
* But the suggested answer is using deprecated functions (escape/unescape)
11+
*
12+
* Properly decode base64 string with UTF-8 support.
13+
* GitHub API returns base64-encoded content that may contain UTF-8 characters like emojis.
14+
*
15+
* The issue: atob() decodes base64 to a binary string, but treats each byte as a Latin-1 character.
16+
* For UTF-8 multi-byte characters (like emojis), this corrupts the data.
17+
*
18+
* The solution: Convert the binary string to a byte array, then use TextDecoder to properly
19+
* interpret those bytes as UTF-8.
20+
*/
21+
export function decodeBase64WithUTF8(base64: string): string {
22+
// Remove whitespace that GitHub API might include
23+
const cleanedBase64 = base64.replace(/\s/g, '');
24+
25+
// Decode base64 to binary string (each character represents a byte)
26+
const binaryString = atob(cleanedBase64);
27+
28+
// Convert binary string to byte array
29+
const bytes = new Uint8Array(binaryString.length);
30+
for (let i = 0; i < binaryString.length; i++) {
31+
bytes[i] = binaryString.charCodeAt(i);
32+
}
33+
34+
// Decode UTF-8 bytes to string
35+
const decoder = new TextDecoder('utf-8');
36+
return decoder.decode(bytes);
37+
}
38+
639

740
export async function defaultGetIssueFn(issueLink: string, githubToken?: string, onError?: (err: unknown) => void): Promise<GithubIssueLinkDataResponse> {
841
const config = parseGithubIssueLink(issueLink);
@@ -32,10 +65,10 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
3265
issueState: issueJson.state,
3366
status: "ok",
3467
owner: config.owner,
35-
repo: config.repo,
68+
repo: config.repo,
3669
reactions: issueJson.reactions,
3770
};
38-
}export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise<GithubPermalinkDataResponse> {
71+
} export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise<GithubPermalinkDataResponse> {
3972
const config = parseGithubPermalinkUrl(permalink);
4073

4174

@@ -61,7 +94,7 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
6194
}
6295

6396
const [contentJson, commitJson] = await Promise.all([contentResult.json(), commitResult.json()]);
64-
const content = atob(contentJson.content);
97+
const content = decodeBase64WithUTF8(contentJson.content);
6598
const lines = content.split("\n");
6699

67100
return {
@@ -76,6 +109,9 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
76109
status: "ok"
77110
};
78111
}
112+
113+
114+
79115
export function handleResponse(response: Response): ErrorResponses {
80116
if (response.status === 404) {
81117
return { status: "404" };
@@ -87,7 +123,7 @@ export function handleResponse(response: Response): ErrorResponses {
87123
};
88124
}
89125

90-
if(response.status === 401) {
126+
if (response.status === 401) {
91127
return {
92128
status: "unauthorized"
93129
}

0 commit comments

Comments
 (0)