Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-pianos-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-github-permalink": patch
---

Fix emoji rendering in code blocks with UTF-8 decoding
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/library/GithubPermalink/GithubPermalink.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export const DifferentLanguages: Story = {
<p>TSX</p>
<GithubPermalink permalink="https://github.com/dwjohnston/react-github-permalink/blob/242681a9df549adcc9a7fca0d8421d98b7e312c4/sample_files/sample1.tsx#L1-L11" />

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


<p>Docker file</p>
Expand Down
33 changes: 33 additions & 0 deletions src/library/GithubPermalink/GithubPermalinkBase.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className="some-parent-component">
<strong>ChildrenStyleOne</strong>
<p>RenderTracker is directly rendered</p>
<button onClick={() => {
setValue((prev) => prev + 1);;
}}>Increase count: {value}</button>
{/* 👇 Here we declare the RenderTracker directly in the component */}
<RenderTracker />
</div>
}`;

export const WithEmoji: Story = {
render: () => (
<GithubPermalinkBase
permalink="https://github.com/dwjohnston/react-renders/src/react-renders/ReactRenders3.tsx#L8-L32"
data={{
lines: codeWithEmoji.split('\n'),
lineFrom: 8,
lineTo: 19,
commit: "b91494b",
path: "src/react-renders/ReactRenders3.tsx",
owner: "dwjohnston",
repo: "react-renders",
commitUrl: "https://github.com/dwjohnston/react-renders/commit/b91494b",
status: "ok"
}}
language="typescript"
/>
),
};
2 changes: 2 additions & 0 deletions src/library/GithubPermalink/github-permalink.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions src/library/config/defaultFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { decodeBase64WithUTF8 } from './defaultFunctions';

describe('Base64 UTF-8 Decoding', () => {
it('should match the deprecated escape/unescape pattern behavior', () => {
const text = '// 👇 This is a comment with emoji';

// 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)));

// 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);


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);
});
});
44 changes: 40 additions & 4 deletions src/library/config/defaultFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@ import { parseGithubIssueLink, parseGithubPermalinkUrl } from "../utils/urlParse
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
* 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.
*
* 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.
*/
export 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<GithubIssueLinkDataResponse> {
const config = parseGithubIssueLink(issueLink);
Expand Down Expand Up @@ -32,10 +65,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<GithubPermalinkDataResponse> {
} export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise<GithubPermalinkDataResponse> {
const config = parseGithubPermalinkUrl(permalink);


Expand All @@ -61,7 +94,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 = decodeBase64WithUTF8(contentJson.content);
const lines = content.split("\n");

return {
Expand All @@ -76,6 +109,9 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string,
status: "ok"
};
}



export function handleResponse(response: Response): ErrorResponses {
if (response.status === 404) {
return { status: "404" };
Expand All @@ -87,7 +123,7 @@ export function handleResponse(response: Response): ErrorResponses {
};
}

if(response.status === 401) {
if (response.status === 401) {
return {
status: "unauthorized"
}
Expand Down