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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/code-display/copy-demo-4x.mp4
Binary file not shown.
165 changes: 165 additions & 0 deletions components/CodeDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { getShikiHighlighter, SHIKI_SUPPORTED_LANGUAGES } from "./shiki";
import { useTheme } from "nextra-theme-docs";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";

type Language = (typeof SHIKI_SUPPORTED_LANGUAGES)[number];

interface CodeDisplayProps {
code: string;
language: Language;
title?: string;
fromLine?: number;
sourceUrl?: string;
}

export default function CodeDisplay({
code,
language,
title = language === "typescript" ? "TypeScript" : language,
fromLine = 1,
sourceUrl,
}: CodeDisplayProps) {
const { theme, systemTheme } = useTheme();
const highlightTheme =
(theme === "system" ? systemTheme : theme) === "dark"
? "github-dark"
: "github-light";
const [highlight, setHighlight] = useState<{
code: string;
language: Language;
theme: string;
html: string;
} | null>(null);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">(
"idle",
);
const copyRequest = useRef(0);
const currentCode = useRef(code);
currentCode.current = code;

useEffect(() => {
let active = true;
getShikiHighlighter()
.then((highlighter) => {
const html = highlighter.codeToHtml(code, {
lang: language,
theme: highlightTheme,
});
if (active)
setHighlight({ code, language, theme: highlightTheme, html });
})
.catch(() => {
// The readable source remains available if highlighting cannot load.
});
return () => {
active = false;
};
}, [code, language, highlightTheme]);

useEffect(() => {
copyRequest.current += 1;
setCopyState("idle");
return () => {
copyRequest.current += 1;
};
}, [code]);

useEffect(() => {
if (copyState !== "copied") return;
const timeout = setTimeout(() => setCopyState("idle"), 2000);
return () => clearTimeout(timeout);
}, [copyState]);

const copy = async () => {
const request = ++copyRequest.current;
try {
await navigator.clipboard.writeText(code);
if (request === copyRequest.current && currentCode.current === code)
setCopyState("copied");
} catch {
if (request === copyRequest.current && currentCode.current === code)
setCopyState("failed");
}
};

const currentHighlight =
highlight?.code === code &&
highlight.language === language &&
highlight.theme === highlightTheme
? highlight.html
: null;
const startLineStyle = { "--start-line": fromLine } as React.CSSProperties;
const lines = code.split("\n");

return (
<div className="my-6 min-w-0 overflow-hidden border border-gray-200 rounded-lg dark:border-gray-800">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b border-gray-200 bg-gray-50 dark:bg-gray-800/50 dark:border-gray-800">
<span className="min-w-0 text-sm font-medium text-gray-700 dark:text-gray-300">
{title}
</span>
<div className="flex shrink-0 items-center gap-3">
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-[44px] min-w-[44px] items-center text-sm underline underline-offset-2"
>
View on GitHub
</a>
)}
<button
type="button"
onClick={copy}
aria-label={`Copy ${title} code`}
className="inline-flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
{copyState === "copied" ? (
<CheckIcon size={16} aria-hidden="true" />
) : (
<ClipboardIcon size={16} aria-hidden="true" />
)}
{copyState === "copied" ? "Copied" : "Copy"}
</button>
</div>
</div>
<span
role="status"
className={
copyState === "failed"
? "block px-4 py-2 text-sm text-red-600 dark:text-red-400"
: "sr-only"
}
>
{copyState === "failed"
? "Could not copy. Select the code and copy it manually."
: copyState === "copied"
? "Code copied."
: ""}
</span>
<div
role="region"
aria-label={`${title} code`}
tabIndex={0}
style={startLineStyle}
className="nextra-code-block nx-relative overflow-x-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 nx-bg-primary-700/5 dark:nx-bg-primary-300/10"
>
{currentHighlight ? (
<div dangerouslySetInnerHTML={{ __html: currentHighlight }} />
) : (
<pre className="shiki">
<code>
{lines.map((line, index) => (
<React.Fragment key={index}>
<span className="line">{line}</span>
{index < lines.length - 1 ? "\n" : null}
</React.Fragment>
))}
</code>
</pre>
)}
</div>
</div>
);
}
174 changes: 68 additions & 106 deletions components/GithubFileReaderDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import {
dedentCode,
getLanguage,
getShikiHighlighter,
} from "@/components/shiki";
import { useTheme } from "nextra-theme-docs";
import React, { useEffect, useMemo, useState } from "react";
import { FaGithub, FaLink, FaSpinner } from "react-icons/fa";
import { dedentCode, getLanguage } from "./shiki";
import CodeDisplay from "./CodeDisplay";
import React, { useEffect, useState } from "react";
import { FaSpinner } from "react-icons/fa";

interface GithubFileReaderDisplayProps {
url: string;
Expand All @@ -15,130 +11,96 @@ interface GithubFileReaderDisplayProps {
dedent?: boolean;
}

const GithubFileReaderDisplay: React.FC<GithubFileReaderDisplayProps> = ({
export default function GithubFileReaderDisplay({
url,
fromLine = 1,
toLine,
title,
dedent = true,
}) => {
const [content, setContent] = useState<string>("");
const [loading, setLoading] = useState(true);
}: GithubFileReaderDisplayProps) {
const [file, setFile] = useState<{ url: string; text: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { theme, systemTheme } = useTheme();

const currentTheme = useMemo(() => {
if (theme === "system") {
return systemTheme;
}

return theme;
}, [systemTheme, theme]);

useEffect(() => {
const fetchAndHighlightContent = async () => {
const controller = new AbortController();
setFile(null);
setError(null);
const fetchContent = async () => {
try {
const rawUrl = url
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");

const [response, highlighter] = await Promise.all([
fetch(rawUrl),
getShikiHighlighter(),
]);

if (!response.ok) {
throw new Error("Failed to fetch file content");
}

const response = await fetch(rawUrl, { signal: controller.signal });
if (!response.ok) throw new Error("Failed to fetch file content");
const text = await response.text();
const lines = text.split("\n");
const selectedLines = lines.slice(fromLine - 1, toLine || lines.length);
let codeContent = selectedLines.join("\n");

// Apply dedentation if enabled
if (dedent) {
codeContent = dedentCode(codeContent);
}

// Set the theme based on current theme
const theme = currentTheme === "dark" ? "github-dark" : "github-light";

// Highlight the code with the current theme and line numbers
const highlightedCode = highlighter.codeToHtml(codeContent, {
lang: getLanguage(url),
theme: theme,
});

// Wrap the highlighted code with a div that sets the starting line number
const wrappedCode = `<div style="--start-line: ${fromLine}">${highlightedCode}</div>`;
setContent(wrappedCode);
setLoading(false);
} catch (err) {
console.error("Highlighting error:", err);
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
if (!controller.signal.aborted) setFile({ url, text });
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error
? error.message
: "Failed to fetch file content",
);
}
};
void fetchContent();
return () => controller.abort();
}, [url]);

fetchAndHighlightContent();
}, [url, fromLine, toLine, currentTheme, dedent]);

if (loading) {
if (error) {
return (
<div className="flex items-center justify-center p-8 border border-gray-200 rounded-lg dark:border-gray-800">
<FaSpinner className="text-2xl text-purple-500 animate-spin" />
<div
role="alert"
className="my-6 rounded-lg border border-red-200 p-4 text-red-600 dark:border-red-800 dark:text-red-400"
>
{error}
</div>
);
}

if (error) {
if (!file || file.url !== url) {
return (
<div className="p-4 text-red-600 border border-red-200 rounded-lg dark:border-red-800 bg-red-50 dark:bg-red-900/20 dark:text-red-400">
Error: {error}
<div
role="status"
aria-label="Loading source code"
className="my-6 flex items-center justify-center rounded-lg border p-8"
>
<FaSpinner
className="animate-spin text-purple-500"
aria-hidden="true"
/>
</div>
);
}

const getGithubLineLink = () => {
// Add line numbers to GitHub URL
const lineFragment = toLine ? `#L${fromLine}-L${toLine}` : `#L${fromLine}`;
return `${url}${lineFragment}`;
};

return (
<div className="my-6 overflow-hidden border border-gray-200 rounded-lg dark:border-gray-800">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 bg-gray-50 dark:bg-gray-800/50 dark:border-gray-800">
<div className="flex items-center space-x-2">
<FaGithub className="text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{title || url.split("/").slice(-1)[0]}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
Lines {fromLine}-{toLine || "end"}
</span>
<a
href={getGithubLineLink()}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 transition-colors duration-200"
aria-label="View on GitHub"
>
<FaLink className="text-sm" />
</a>
</div>
const lines = file.text.split("\n");
if (
!Number.isInteger(fromLine) ||
fromLine < 1 ||
fromLine > lines.length ||
(toLine !== undefined &&
(!Number.isInteger(toLine) || toLine < fromLine || toLine > lines.length))
) {
return (
<div
role="alert"
className="my-6 rounded-lg border border-red-200 p-4 text-red-600 dark:text-red-400"
>
The source line range is unavailable.
</div>
);
}
const selected = lines.slice(fromLine - 1, toLine ?? lines.length).join("\n");
const code = dedent ? dedentCode(selected) : selected;
const fragment = toLine ? `#L${fromLine}-L${toLine}` : `#L${fromLine}`;

<div className="nextra-code-block nx-relative">
<div
className="nx-bg-primary-700/5 nx-overflow-x-auto nx-font-medium nx-subpixel-antialiased dark:nx-bg-primary-300/10 nx-text-[.9em]"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
return (
<CodeDisplay
code={code}
language={getLanguage(url)}
title={title ?? url.split("/").pop()}
fromLine={fromLine}
sourceUrl={`${url}${fragment}`}
/>
);
};

export default GithubFileReaderDisplay;
}
1 change: 1 addition & 0 deletions components/shiki.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

export const SHIKI_SUPPORTED_LANGUAGES = [
"rust",
"bash",
"typescript",
"javascript",
"solidity",
Expand Down
Loading
Loading