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
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/src/library/GithubPermalink/GithubPermalink.stories.tsx b/src/library/GithubPermalink/GithubPermalink.stories.tsx
index 543bdb7..3696dc2 100644
--- a/src/library/GithubPermalink/GithubPermalink.stories.tsx
+++ b/src/library/GithubPermalink/GithubPermalink.stories.tsx
@@ -37,8 +37,8 @@ export const DifferentLanguages: Story = {
TSX
- TSX2
-
+ TSX with Emoji
+
Docker file
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
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 {
diff --git a/src/library/config/defaultFunctions.test.ts b/src/library/config/defaultFunctions.test.ts
new file mode 100644
index 0000000..8a97d9e
--- /dev/null
+++ b/src/library/config/defaultFunctions.test.ts
@@ -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);
+ });
+});
diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts
index 63f54ad..a057fed 100644
--- a/src/library/config/defaultFunctions.ts
+++ b/src/library/config/defaultFunctions.ts
@@ -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 {
const config = parseGithubIssueLink(issueLink);
@@ -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 {
+} export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise {
const config = parseGithubPermalinkUrl(permalink);
@@ -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 {
@@ -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" };
@@ -87,7 +123,7 @@ export function handleResponse(response: Response): ErrorResponses {
};
}
- if(response.status === 401) {
+ if (response.status === 401) {
return {
status: "unauthorized"
}