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" }