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,148 @@
# Kind 1337 Code Snippet Renderer (NIP-C0)
## Overview
Added complete support for kind 1337 (Code Snippet) events from NIP-C0, with both feed and detail renderers.
## Files Created
### 1. Helper Functions (`src/lib/nip-c0-helpers.ts`)
Tag extraction utilities using applesauce-core:
- `getCodeLanguage()` - Programming language (l tag)
- `getCodeName()` - Filename (name tag)
- `getCodeExtension()` - File extension without dot (extension tag)
- `getCodeDescription()` - Description text (description tag)
- `getCodeRuntime()` - Runtime specification (runtime tag)
- `getCodeLicenses()` - Array of license identifiers (license tags)
- `getCodeDependencies()` - Array of dependencies (dep tags)
- `getCodeRepo()` - Repository reference with type detection (URL or NIP-34 address)
### 2. Feed Renderer (`src/components/nostr/kinds/Kind1337Renderer.tsx`)
Compact view showing:
- **Clickable title** - Opens detail view, uses filename or "Code Snippet"
- **Language badge** - Shows programming language in styled chip
- **Description** - Truncated to 2 lines if present
- **Code preview** - First 5 lines with line-clamp and "..." indicator
- Wrapped in `BaseEventContainer` for consistency
### 3. Detail Renderer (`src/components/nostr/kinds/Kind1337DetailRenderer.tsx`)
Full view with:
- **Header** - Title with FileCode icon
- **Metadata section** (before code):
- Language and Extension
- Description
- Runtime (if present)
- Licenses (if present)
- Dependencies list (if present)
- Repository link:
- NIP-34 address → clickable, opens repository event
- URL → external link with icon
- **Code section**:
- Full code in `<pre>` with `font-mono` styling
- Copy button inline (absolute positioned top-right)
- Matches JsonViewer pattern
## Integration Points
### Kinds Registry
Already existed in `src/constants/kinds.ts`:
```typescript
1337: {
kind: 1337,
name: "Code",
description: "Code Snippet",
nip: "C0",
icon: FileCode,
}
```
### Event Title System
Added to `src/lib/event-title.ts`:
```typescript
case 1337: // Code snippet
title = getCodeName(event);
break;
```
Window titles show filename or fall back to "Code Snippet"
### Renderer Registry
Added to `src/components/nostr/kinds/index.tsx`:
```typescript
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
```
## Features
### Feed View
- Clean, compact display
- Language identification at a glance
- Quick code preview without opening
- Clickable to open full view
### Detail View
- Complete metadata display
- NIP-34 repository integration (handles both URLs and Nostr addresses)
- One-click copy functionality
- Clean, readable code display
- Professional layout with metadata organized before code
## Design Decisions
### No Syntax Highlighting (MVP)
- Uses plain `<pre>` with `font-mono` styling (matching JsonViewer)
- No new dependencies added
- Can add syntax highlighting later (react-syntax-highlighter) as enhancement
### No Download Button
- Simplified to just Copy functionality
- Users can copy and save manually
- Reduces UI complexity
### Metadata Before Code
- More scannable - users see what the code is before reading it
- Follows natural information hierarchy
- Easier to understand context
### Copy Button Position
- Inline in code section (top-right absolute)
- Matches existing JsonViewer pattern
- Consistent UX across app
## NIP-C0 Compliance
Supports all NIP-C0 tags:
-`l` - Programming language
-`name` - Filename
-`extension` - File extension
-`description` - Description
-`runtime` - Runtime specification
-`license` - License(s) (supports multiple)
-`dep` - Dependencies (supports multiple)
-`repo` - Repository reference (URL or NIP-34)
## Future Enhancements
Potential improvements for Phase 2:
1. **Syntax Highlighting** - Add react-syntax-highlighter for color-coded display
2. **Line Numbers** - Optional line numbers for code blocks
3. **Code Formatting** - Auto-format/prettify code
4. **Run Functionality** - Execute supported languages (complex, low priority)
5. **Download Button** - Add back if users request it
6. **Diff View** - For patches or code changes
## Testing
- ✅ Type check passes
- ✅ Integrates with existing event title system
- ✅ Follows established component patterns
- ✅ Uses applesauce-core helpers consistently
- ✅ NIP-34 repository links handled correctly
## Usage
Users can now:
1. View code snippets in feeds with preview
2. Click to open full detail view
3. See all metadata (language, runtime, deps, etc.)
4. Copy code with one click
5. Navigate to referenced repositories (NIP-34 or URLs)
6. See proper window titles with filenames

18
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"dexie-react-hooks": "^4.2.0",
"jotai": "^2.15.2",
"lucide-react": "latest",
"prismjs": "^1.30.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
@@ -48,6 +49,7 @@
"@eslint/js": "^9.17.0",
"@react-router/dev": "^7.1.0",
"@types/node": "^24.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
@@ -3841,6 +3843,13 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@@ -8033,6 +8042,15 @@
"node": ">=6.0.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",

View File

@@ -40,6 +40,7 @@
"dexie-react-hooks": "^4.2.0",
"jotai": "^2.15.2",
"lucide-react": "latest",
"prismjs": "^1.30.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
@@ -56,6 +57,7 @@
"@eslint/js": "^9.17.0",
"@react-router/dev": "^7.1.0",
"@types/node": "^24.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",

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