feat: syntax highlighting

This commit is contained in:
Alejandro Gómez
2025-12-15 13:11:59 +01:00
parent dab250260f
commit 19cdde0110
40 changed files with 1006 additions and 122 deletions

View File

@@ -0,0 +1,33 @@
import { Copy, CopyCheck } from "lucide-react";
interface CodeCopyButtonProps {
onCopy: () => void;
copied: boolean;
label?: string;
className?: string;
}
/**
* Reusable copy button for code blocks with consistent styling
* Designed to be absolutely positioned over code containers
*/
export function CodeCopyButton({
onCopy,
copied,
label = "Copy code",
className = "",
}: CodeCopyButtonProps) {
return (
<button
onClick={onCopy}
className={`absolute top-2 right-2 p-2 bg-background/90 hover:bg-muted border border-border rounded transition-colors ${className}`.trim()}
aria-label={label}
>
{copied ? (
<CopyCheck className="size-4 text-muted-foreground" />
) : (
<Copy className="size-4 text-muted-foreground" />
)}
</button>
);
}

View File

@@ -222,7 +222,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
</div>
{getEventDisplayTitle(event, false)}
<span> - </span>
<UserName pubkey={event.pubkey} />
<UserName pubkey={event.pubkey} className="text-inherit" />
</div>
);
}, [appId, event]);

View File

@@ -2,14 +2,15 @@ import { useState } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./nostr/kinds";
import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer";
import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
import { Kind0DetailRenderer } from "./nostr/kinds/ProfileDetailRenderer";
import { Kind3DetailView } from "./nostr/kinds/ContactListRenderer";
import { IssueDetailRenderer } from "./nostr/kinds/IssueDetailRenderer";
import { PatchDetailRenderer } from "./nostr/kinds/PatchDetailRenderer";
import { PullRequestDetailRenderer } from "./nostr/kinds/PullRequestDetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
import { Kind1337DetailRenderer } from "./nostr/kinds/CodeSnippetDetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/HighlightDetailRenderer";
import { Kind10002DetailRenderer } from "./nostr/kinds/RelayListDetailRenderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/ArticleDetailRenderer";
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
import { JsonViewer } from "./JsonViewer";
import { RelayLink } from "./nostr/RelayLink";
@@ -269,6 +270,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
<Kind0DetailRenderer event={event} />
) : event.kind === kinds.Contacts ? (
<Kind3DetailView event={event} />
) : event.kind === 1337 ? (
<Kind1337DetailRenderer event={event} />
) : event.kind === 1617 ? (
<PatchDetailRenderer event={event} />
) : event.kind === 1618 ? (

View File

@@ -1,12 +1,12 @@
import { CopyCheck, Copy } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useCopy } from "../hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
interface JsonViewerProps {
data: any;
@@ -31,27 +31,21 @@ export function JsonViewer({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col rounded-none">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-2 relative">
<pre className="text-xs font-mono bg-muted p-4 pr-10 overflow-scroll">
<Button
size="icon"
variant="link"
onClick={handleCopy}
aria-label="Copy JSON"
className="absolute top-2 right-2"
>
{copied ? (
<CopyCheck className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</Button>
{jsonString}
</pre>
<div className="flex-1 overflow-auto relative">
<SyntaxHighlight
code={jsonString}
language="json"
className="bg-muted p-4 pr-10 overflow-scroll"
/>
<CodeCopyButton
onCopy={handleCopy}
copied={copied}
label="Copy JSON"
/>
</div>
</DialogContent>
</Dialog>

View File

@@ -15,13 +15,11 @@ import {
Shield,
Filter as FilterIcon,
Download,
Copy,
Clock,
User,
Hash,
Search,
Code,
CopyCheck,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -70,6 +68,8 @@ import {
} from "@/lib/filter-formatters";
import { sanitizeFilename } from "@/lib/filename-utils";
import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
const MemoizedFeedEvent = memo(
@@ -639,22 +639,16 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</CollapsibleTrigger>
<CollapsibleContent>
<div className="relative mt-2">
<pre className="text-xs bg-muted/50 p-3 pr-10 overflow-x-auto font-mono border border-border/40 rounded">
<Button
size="icon"
variant="link"
onClick={() => handleCopy(JSON.stringify(filter, null, 2))}
aria-label="Copy query JSON"
className="absolute top-2 right-2"
>
{copied ? (
<CopyCheck className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</Button>
{JSON.stringify(filter, null, 2)}
</pre>
<SyntaxHighlight
code={JSON.stringify(filter, null, 2)}
language="json"
className="bg-muted/50 p-3 pr-10 overflow-x-auto border border-border/40 rounded"
/>
<CodeCopyButton
onCopy={() => handleCopy(JSON.stringify(filter, null, 2))}
copied={copied}
label="Copy query JSON"
/>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from "react";
import Prism from "prismjs";
// Core languages
import "prismjs/components/prism-diff";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-json";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-css";
import "prismjs/components/prism-python";
import "prismjs/components/prism-yaml";
interface SyntaxHighlightProps {
code: string;
language:
| "diff"
| "javascript"
| "typescript"
| "jsx"
| "tsx"
| "bash"
| "shell"
| "json"
| "markdown"
| "css"
| "python"
| "yaml";
className?: string;
showLineNumbers?: boolean;
}
/**
* Syntax highlighting component using Prism.js
* Matches Grimoire's dark theme using CSS custom properties
*
* @example
* ```tsx
* <SyntaxHighlight code={patchContent} language="diff" />
* ```
*/
export function SyntaxHighlight({
code,
language,
className = "",
showLineNumbers = false,
}: SyntaxHighlightProps) {
const codeRef = useRef<HTMLElement>(null);
// Normalize language aliases
const normalizedLanguage = language === "shell" ? "bash" : language;
useEffect(() => {
// Check for browser environment (SSR safety)
if (typeof window === "undefined" || !codeRef.current) return;
// Highlight the code element
Prism.highlightElement(codeRef.current);
}, [code, normalizedLanguage]);
return (
<pre
className={`language-${normalizedLanguage} ${showLineNumbers ? "line-numbers" : ""} ${className}`.trim()}
>
<code ref={codeRef} className={`language-${normalizedLanguage}`}>
{code}
</code>
</pre>
);
}

View File

@@ -11,6 +11,7 @@ import {
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { MediaEmbed } from "../MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { useGrimoire } from "@/core/state";
import type { NostrEvent } from "@/types/nostr";
@@ -263,12 +264,32 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ ...props }: any) => (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : null;
const code = String(children).replace(/\n$/, "");
// Inline code (no language)
if (!language) {
return (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting
return (
<SyntaxHighlight
code={code}
language={language as any}
className="my-4"
/>
);
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"

View File

@@ -0,0 +1,222 @@
import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import { ExternalLink } from "lucide-react";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import {
getCodeLanguage,
getCodeName,
getCodeExtension,
getCodeDescription,
getCodeRuntime,
getCodeLicenses,
getCodeDependencies,
getCodeRepo,
} from "@/lib/nip-c0-helpers";
import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
interface Kind1337DetailRendererProps {
event: NostrEvent;
}
/**
* Detail renderer for Kind 1337 - Code Snippet (NIP-C0)
* Full view with all metadata and complete code
*/
export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const name = useMemo(() => getCodeName(event), [event]);
const language = useMemo(() => getCodeLanguage(event), [event]);
const extension = useMemo(() => getCodeExtension(event), [event]);
const description = useMemo(() => getCodeDescription(event), [event]);
const runtime = useMemo(() => getCodeRuntime(event), [event]);
const licenses = useMemo(() => getCodeLicenses(event), [event]);
const dependencies = useMemo(() => getCodeDependencies(event), [event]);
const repo = useMemo(() => getCodeRepo(event), [event]);
// Parse NIP-34 repository address if present
const repoPointer = useMemo(() => {
if (!repo || repo.type !== "nip34") return null;
try {
const [kindStr, pubkey, identifier] = repo.value.split(":");
return {
kind: parseInt(kindStr),
pubkey,
identifier,
};
} catch {
return null;
}
}, [repo]);
// Fetch repository event if NIP-34 address
const repoEvent = useNostrEvent(repoPointer || undefined);
const repoName = repoEvent
? getRepositoryName(repoEvent) ||
getRepositoryIdentifier(repoEvent) ||
"Repository"
: repo?.type === "nip34"
? repo.value.split(":")[2] || "Unknown Repository"
: null;
const handleCopyCode = () => {
copy(event.content);
};
const handleRepoClick = () => {
if (repoPointer) {
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
}
};
// Normalize language to supported Prism languages
const normalizedLanguage = useMemo(() => {
if (!language) return null;
const lang = language.toLowerCase();
// Map common language names to Prism identifiers
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
shell: "bash",
yml: "yaml",
};
const mapped = languageMap[lang] || lang;
// Check if it's a supported language
const supported = [
"javascript",
"typescript",
"jsx",
"tsx",
"bash",
"json",
"markdown",
"css",
"python",
"yaml",
"diff",
];
return supported.includes(mapped) ? mapped : null;
}, [language]);
return (
<div className="flex flex-col gap-2 p-6">
{/* Header */}
<h1 className="text-2xl font-bold">{name || "Code Snippet"}</h1>
{/* Description */}
{description && <p>{description}</p>}
{/* Metadata Section */}
<div className="grid grid-cols-2 gap-2 py-2 text-sm">
{language && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Language</h3>
<span className="font-mono">{language}</span>
</div>
)}
{extension && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Extension</h3>
<span className="font-mono">.{extension}</span>
</div>
)}
{/* Runtime */}
{runtime && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Runtime</h3>
<span className="font-mono">{runtime}</span>
</div>
)}
{/* Licenses */}
{licenses.length > 0 && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">License</h3>
<span>{licenses.join(", ")}</span>
</div>
)}
{/* Dependencies */}
{dependencies.length > 0 && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Dependencies</h3>
<div className="flex gap-1 items-center flex-wrap">
{dependencies.map((dep, idx) => (
<Label key={idx} className="p-0.5">
{dep}
</Label>
))}
</div>
</div>
)}
{/* Repository */}
{repo && (
<div className="flex flex-col gap-1">
<h3 className="text-muted-foreground">Repository</h3>
{repo.type === "url" ? (
<a
href={repo.value}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
{repo.value}
<ExternalLink className="size-3" />
</a>
) : (
<button
onClick={handleRepoClick}
className="inline-flex items-center gap-1 text-primary hover:underline cursor-crosshair"
>
{repoName}
</button>
)}
</div>
)}
</div>
{/* Code Section */}
<div className="relative">
{normalizedLanguage ? (
<>
<SyntaxHighlight
code={event.content}
language={normalizedLanguage as any}
className="bg-muted p-4 pr-10 border border-border overflow-x-auto"
/>
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
</>
) : (
<pre className="text-xs font-mono bg-muted p-4 pr-10 border border-border overflow-x-auto">
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
<code>{event.content}</code>
</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
getCodeLanguage,
getCodeName,
getCodeDescription,
} from "@/lib/nip-c0-helpers";
import { Label } from "@/components/ui/Label";
/**
* Renderer for Kind 1337 - Code Snippet (NIP-C0)
* Displays code snippet name, language, description, and preview in feed
*/
export function Kind1337Renderer({ event }: BaseEventProps) {
const name = getCodeName(event);
const language = getCodeLanguage(event);
const description = getCodeDescription(event);
// Get first 3-5 lines for preview
const codeLines = event.content.split("\n");
const previewLines = codeLines.slice(0, 5);
const hasMore = codeLines.length > 5;
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
{/* Title */}
<ClickableEventTitle
event={event}
windowTitle={name || "Code Snippet"}
className="text-lg font-semibold text-foreground"
>
{name || "Code Snippet"}
</ClickableEventTitle>
{/* Language Badge */}
{language && (
<div className="flex items-center gap-2">
<Label>{language}</Label>
</div>
)}
</div>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{description}
</p>
)}
{/* Code Preview */}
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 border border-border overflow-x-auto">
<code className="line-clamp-5">
{previewLines.join("\n")}
{hasMore && "\n..."}
</code>
</pre>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -7,6 +7,7 @@ import { Tag, FolderGit2 } from "lucide-react";
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { MediaEmbed } from "../MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import type { NostrEvent } from "@/types/nostr";
@@ -19,6 +20,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
/**
* Component to render nostr: mentions inline
@@ -214,12 +216,9 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
<div className="flex flex-wrap items-center gap-2">
<Tag className="size-3 text-muted-foreground" />
{labels.map((label, idx) => (
<span
key={idx}
className="px-3 py-1 border border-muted border-dotted text-muted-foreground text-xs"
>
<Label key={idx} size="md">
{label}
</span>
</Label>
))}
</div>
)}
@@ -282,12 +281,32 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ ...props }: any) => (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : null;
const code = String(children).replace(/\n$/, "");
// Inline code (no language)
if (!language) {
return (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting
return (
<SyntaxHighlight
code={code}
language={language as any}
className="my-4"
/>
);
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
@@ -309,7 +328,7 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
hr: () => <hr className="my-4" />,
}}
>
{event.content.replace(/\\n/g, '\n')}
{event.content.replace(/\\n/g, "\n")}
</ReactMarkdown>
</article>
) : (

View File

@@ -16,6 +16,7 @@ import {
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { UserName } from "../UserName";
import { Label } from "@/components/ui/Label";
/**
* Renderer for Kind 1621 - Issue
@@ -103,12 +104,7 @@ export function IssueRenderer({ event }: BaseEventProps) {
items-center gap-1 overflow-x-scroll my-1"
>
{labels.map((label, idx) => (
<span
key={idx}
className="px-2 py-0.5 border border-muted border-dotted text-xs text-muted-foreground"
>
{label}
</span>
<Label key={idx}>{label}</Label>
))}
</div>
)}

View File

@@ -1,9 +1,11 @@
import { useMemo } from "react";
import { GitCommit, FolderGit2, User, Copy, CopyCheck } from "lucide-react";
import { UserName } from "../UserName";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import type { NostrEvent } from "@/types/nostr";
import {
getPatchSubject,
@@ -203,20 +205,16 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
<section className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Patch</h2>
<div className="relative">
<pre className="overflow-x-auto text-xs font-mono bg-muted/30 p-4 whitespace-pre-wrap break-words">
{event.content}
</pre>
<button
onClick={() => copy(event.content)}
className="absolute top-2 right-2 p-2 bg-background/90 hover:bg-muted border border-border rounded"
aria-label="Copy patch"
>
{copied ? (
<CopyCheck className="size-4 text-muted-foreground" />
) : (
<Copy className="size-4 text-muted-foreground" />
)}
</button>
<SyntaxHighlight
code={event.content}
language="diff"
className="overflow-x-auto bg-muted/30 p-4"
/>
<CodeCopyButton
onCopy={() => copy(event.content)}
copied={copied}
label="Copy patch"
/>
</div>
</section>
)}

View File

@@ -7,6 +7,7 @@ import { GitBranch, FolderGit2, Tag, Copy, CopyCheck } from "lucide-react";
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { MediaEmbed } from "../MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
@@ -24,6 +25,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
/**
* Component to render nostr: mentions inline
@@ -227,12 +229,9 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
<div className="flex flex-wrap items-center gap-2">
<Tag className="size-3 text-muted-foreground" />
{labels.map((label, idx) => (
<span
key={idx}
className="px-3 py-1 border border-muted border-dotted text-muted-foreground text-xs"
>
<Label key={idx} size="md">
{label}
</span>
</Label>
))}
</div>
)}
@@ -396,12 +395,32 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ ...props }: any) => (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : null;
const code = String(children).replace(/\n$/, "");
// Inline code (no language)
if (!language) {
return (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting
return (
<SyntaxHighlight
code={code}
language={language as any}
className="my-4"
/>
);
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
@@ -423,7 +442,7 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
hr: () => <hr className="my-4" />,
}}
>
{event.content.replace(/\\n/g, '\n')}
{event.content.replace(/\\n/g, "\n")}
</ReactMarkdown>
</article>
</>

View File

@@ -16,6 +16,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
/**
* Renderer for Kind 1618 - Pull Request
@@ -111,12 +112,7 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
{labels.length > 0 && (
<div className="flex items-center gap-1 overflow-x-scroll">
{labels.map((label, idx) => (
<span
key={idx}
className="px-2 py-0.5 border border-muted border-dotted text-xs text-muted-foreground"
>
{label}
</span>
<Label key={idx}>{label}</Label>
))}
</div>
)}

View File

@@ -1,22 +1,23 @@
import { Kind0Renderer } from "./Kind0Renderer";
import { Kind1Renderer } from "./Kind1Renderer";
import { Kind3Renderer } from "./Kind3Renderer";
import { Kind0Renderer } from "./ProfileRenderer";
import { Kind1Renderer } from "./NoteRenderer";
import { Kind3Renderer } from "./ContactListRenderer";
import { RepostRenderer } from "./RepostRenderer";
import { Kind7Renderer } from "./Kind7Renderer";
import { Kind9Renderer } from "./Kind9Renderer";
import { Kind20Renderer } from "./Kind20Renderer";
import { Kind21Renderer } from "./Kind21Renderer";
import { Kind22Renderer } from "./Kind22Renderer";
import { Kind1063Renderer } from "./Kind1063Renderer";
import { Kind7Renderer } from "./ReactionRenderer";
import { Kind9Renderer } from "./ChatMessageRenderer";
import { Kind20Renderer } from "./PictureRenderer";
import { Kind21Renderer } from "./VideoRenderer";
import { Kind22Renderer } from "./ShortVideoRenderer";
import { Kind1063Renderer } from "./FileMetadataRenderer";
import { Kind1337Renderer } from "./CodeSnippetRenderer";
import { IssueRenderer } from "./IssueRenderer";
import { PatchRenderer } from "./PatchRenderer";
import { PullRequestRenderer } from "./PullRequestRenderer";
import { Kind9735Renderer } from "./Kind9735Renderer";
import { Kind9802Renderer } from "./Kind9802Renderer";
import { Kind10002Renderer } from "./Kind10002Renderer";
import { Kind30023Renderer } from "./Kind30023Renderer";
import { Kind9735Renderer } from "./ZapReceiptRenderer";
import { Kind9802Renderer } from "./HighlightRenderer";
import { Kind10002Renderer } from "./RelayListRenderer";
import { Kind30023Renderer } from "./ArticleRenderer";
import { RepositoryRenderer } from "./RepositoryRenderer";
import { Kind39701Renderer } from "./Kind39701Renderer";
import { Kind39701Renderer } from "./BookmarkRenderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -38,6 +39,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
22: Kind22Renderer, // Short Video (NIP-71)
1063: Kind1063Renderer, // File Metadata (NIP-94)
1111: Kind1Renderer, // Post
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
@@ -99,16 +101,16 @@ export {
EventMenu,
} from "./BaseEventRenderer";
export type { BaseEventProps } from "./BaseEventRenderer";
export { Kind1Renderer } from "./Kind1Renderer";
export { Kind1Renderer } from "./NoteRenderer";
export {
RepostRenderer,
Kind6Renderer,
Kind16Renderer,
} from "./RepostRenderer";
export { Kind7Renderer } from "./Kind7Renderer";
export { Kind9Renderer } from "./Kind9Renderer";
export { Kind20Renderer } from "./Kind20Renderer";
export { Kind21Renderer } from "./Kind21Renderer";
export { Kind22Renderer } from "./Kind22Renderer";
export { Kind1063Renderer } from "./Kind1063Renderer";
export { Kind9735Renderer } from "./Kind9735Renderer";
export { Kind7Renderer } from "./ReactionRenderer";
export { Kind9Renderer } from "./ChatMessageRenderer";
export { Kind20Renderer } from "./PictureRenderer";
export { Kind21Renderer } from "./VideoRenderer";
export { Kind22Renderer } from "./ShortVideoRenderer";
export { Kind1063Renderer } from "./FileMetadataRenderer";
export { Kind9735Renderer } from "./ZapReceiptRenderer";

View File

@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
interface LabelProps {
children: React.ReactNode;
className?: string;
/**
* Size variant for the label
* - sm: px-2 py-0.5 (default)
* - md: px-3 py-1
*/
size?: "sm" | "md";
}
/**
* Label/Badge component with dotted border styling
* Used for tags, language indicators, and metadata labels
*/
export function Label({ children, className, size = "sm" }: LabelProps) {
return (
<span
className={cn(
"border border-muted border-dotted text-muted-foreground text-xs",
size === "sm" && "px-2 py-0.5",
size === "md" && "px-3 py-1",
className,
)}
>
{children}
</span>
);
}

View File

@@ -1,3 +1,6 @@
/* Prism syntax highlighting theme */
@import './styles/prism-theme.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -8,6 +8,7 @@ import {
getPatchSubject,
getPullRequestSubject,
} from "@/lib/nip34-helpers";
import { getCodeName } from "@/lib/nip-c0-helpers";
import { getKindInfo } from "@/constants/kinds";
/**
@@ -35,6 +36,9 @@ export function getEventDisplayTitle(
case 30617: // Repository
title = getRepositoryName(event);
break;
case 1337: // Code snippet
title = getCodeName(event);
break;
case 1621: // Issue
title = getIssueTitle(event);
break;

96
src/lib/nip-c0-helpers.ts Normal file
View File

@@ -0,0 +1,96 @@
import { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
function getTagValues(event: NostrEvent, tag: string) {
return event.tags.filter((t) => t[0] === tag).map((t) => t[1]);
}
/**
* NIP-C0 Code Snippet Helpers
* Extract metadata from kind 1337 code snippet events
*/
/**
* Get the programming language
* @param event - Code snippet event
* @returns Language name (e.g., "javascript", "python")
*/
export function getCodeLanguage(event: NostrEvent): string | undefined {
return getTagValue(event, "l");
}
/**
* Get the code snippet name/filename
* @param event - Code snippet event
* @returns Filename (e.g., "hello-world.js")
*/
export function getCodeName(event: NostrEvent): string | undefined {
return getTagValue(event, "name");
}
/**
* Get the file extension
* @param event - Code snippet event
* @returns Extension without dot (e.g., "js", "py")
*/
export function getCodeExtension(event: NostrEvent): string | undefined {
return getTagValue(event, "extension");
}
/**
* Get the code description
* @param event - Code snippet event
* @returns Description text
*/
export function getCodeDescription(event: NostrEvent): string | undefined {
return getTagValue(event, "description");
}
/**
* Get the runtime specification
* @param event - Code snippet event
* @returns Runtime string (e.g., "node v18.15.0", "python 3.11")
*/
export function getCodeRuntime(event: NostrEvent): string | undefined {
return getTagValue(event, "runtime");
}
/**
* Get all licenses
* @param event - Code snippet event
* @returns Array of license identifiers (e.g., ["MIT"], ["GPL-3.0-or-later", "Apache-2.0"])
*/
export function getCodeLicenses(event: NostrEvent): string[] {
return getTagValues(event, "license");
}
/**
* Get all dependencies
* @param event - Code snippet event
* @returns Array of dependency strings
*/
export function getCodeDependencies(event: NostrEvent): string[] {
return getTagValues(event, "dep");
}
/**
* Get repository reference
* @param event - Code snippet event
* @returns Repository info with type (url or nip34) and value
*/
export function getCodeRepo(
event: NostrEvent,
):
| { type: "url"; value: string }
| { type: "nip34"; value: string }
| undefined {
const repoTag = event.tags.find((t) => t[0] === "repo");
if (!repoTag || !repoTag[1]) return undefined;
const value = repoTag[1];
// Check if it's NIP-34 address format (30617:pubkey:dtag)
if (value.startsWith("30617:")) {
return { type: "nip34", value };
}
return { type: "url", value };
}

145
src/styles/prism-theme.css Normal file
View File

@@ -0,0 +1,145 @@
/* Grimoire Prism Theme - Matches dark theme using CSS variables */
code[class*="language-"],
pre[class*="language-"] {
color: hsl(var(--foreground));
background: none;
text-shadow: none;
font-family: 'Oxygen Mono', monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 4;
hyphens: none;
}
/* Diff-specific tokens */
/* Deleted lines (red) - subtle background, no strikethrough */
.token.deleted {
color: #ff8787;
background: rgba(255, 59, 48, 0.1);
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Added lines (green) - subtle background */
.token.inserted {
color: #69db7c;
background: rgba(52, 199, 89, 0.1);
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Hunk headers (@@ -1,5 +1,7 @@) - cyan/blue */
.token.diff.coord,
.token.coord {
color: #66d9ef;
background: rgba(102, 217, 239, 0.08);
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
font-style: normal;
}
/* File headers (diff --git, ---, +++) */
.token.diff.range,
.token.prefix.unchanged,
.language-diff .token.unchanged {
color: hsl(var(--muted-foreground));
font-weight: normal;
}
/* Prefix characters (+/-) */
.language-diff .token.prefix {
font-weight: 700;
opacity: 0.7;
}
/* General syntax tokens */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: hsl(var(--muted-foreground));
}
.token.punctuation {
color: hsl(var(--foreground) / 0.7);
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol {
color: hsl(var(--primary));
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin {
color: hsl(var(--muted-foreground));
font-weight: 500;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: hsl(var(--foreground));
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: hsl(var(--primary));
}
.token.function,
.token.class-name {
color: hsl(var(--primary));
font-weight: bold;
}
.token.regex,
.token.important,
.token.variable {
color: hsl(var(--primary));
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line highlighting */
pre[class*="language-"] > code {
display: block;
}
/* Optional: Line numbers support */
.line-numbers .line-numbers-rows {
border-right: 1px solid hsl(var(--border));
}
.line-numbers-rows > span:before {
color: hsl(var(--muted-foreground));
}