From 19cdde0110ab62c2f497b9d318acf63e3383ec6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Mon, 15 Dec 2025 13:11:59 +0100 Subject: [PATCH] feat: syntax highlighting --- claudedocs/kind-1337-code-snippets.md | 148 ++++++++++++ package-lock.json | 18 ++ package.json | 2 + src/components/CodeCopyButton.tsx | 33 +++ src/components/DynamicWindowTitle.tsx | 2 +- src/components/EventDetailViewer.tsx | 13 +- src/components/JsonViewer.tsx | 34 ++- src/components/ReqViewer.tsx | 30 +-- src/components/SyntaxHighlight.tsx | 73 ++++++ ...Renderer.tsx => ArticleDetailRenderer.tsx} | 33 ++- ...d30023Renderer.tsx => ArticleRenderer.tsx} | 0 ...39701Renderer.tsx => BookmarkRenderer.tsx} | 0 ...d9Renderer.tsx => ChatMessageRenderer.tsx} | 0 .../nostr/kinds/CodeSnippetDetailRenderer.tsx | 222 ++++++++++++++++++ .../nostr/kinds/CodeSnippetRenderer.tsx | 67 ++++++ ...d3Renderer.tsx => ContactListRenderer.tsx} | 0 ...3Renderer.tsx => FileMetadataRenderer.tsx} | 0 ...nderer.tsx => HighlightDetailRenderer.tsx} | 0 ...9802Renderer.tsx => HighlightRenderer.tsx} | 0 .../nostr/kinds/IssueDetailRenderer.tsx | 43 +++- src/components/nostr/kinds/IssueRenderer.tsx | 8 +- .../{Kind1Renderer.tsx => NoteRenderer.tsx} | 0 .../nostr/kinds/PatchDetailRenderer.tsx | 26 +- ...Kind20Renderer.tsx => PictureRenderer.tsx} | 0 ...Renderer.tsx => ProfileDetailRenderer.tsx} | 0 ...{Kind0Renderer.tsx => ProfileRenderer.tsx} | 0 .../nostr/kinds/PullRequestDetailRenderer.tsx | 43 +++- .../nostr/kinds/PullRequestRenderer.tsx | 8 +- ...Kind7Renderer.tsx => ReactionRenderer.tsx} | 0 ...nderer.tsx => RelayListDetailRenderer.tsx} | 0 ...0002Renderer.tsx => RelayListRenderer.tsx} | 0 ...d22Renderer.tsx => ShortVideoRenderer.tsx} | 0 .../{Kind21Renderer.tsx => VideoRenderer.tsx} | 0 ...735Renderer.tsx => ZapReceiptRenderer.tsx} | 0 src/components/nostr/kinds/index.tsx | 46 ++-- src/components/ui/Label.tsx | 31 +++ src/index.css | 3 + src/lib/event-title.ts | 4 + src/lib/nip-c0-helpers.ts | 96 ++++++++ src/styles/prism-theme.css | 145 ++++++++++++ 40 files changed, 1006 insertions(+), 122 deletions(-) create mode 100644 claudedocs/kind-1337-code-snippets.md create mode 100644 src/components/CodeCopyButton.tsx create mode 100644 src/components/SyntaxHighlight.tsx rename src/components/nostr/kinds/{Kind30023DetailRenderer.tsx => ArticleDetailRenderer.tsx} (90%) rename src/components/nostr/kinds/{Kind30023Renderer.tsx => ArticleRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind39701Renderer.tsx => BookmarkRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind9Renderer.tsx => ChatMessageRenderer.tsx} (100%) create mode 100644 src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/CodeSnippetRenderer.tsx rename src/components/nostr/kinds/{Kind3Renderer.tsx => ContactListRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind1063Renderer.tsx => FileMetadataRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind9802DetailRenderer.tsx => HighlightDetailRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind9802Renderer.tsx => HighlightRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind1Renderer.tsx => NoteRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind20Renderer.tsx => PictureRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind0DetailRenderer.tsx => ProfileDetailRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind0Renderer.tsx => ProfileRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind7Renderer.tsx => ReactionRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind10002DetailRenderer.tsx => RelayListDetailRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind10002Renderer.tsx => RelayListRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind22Renderer.tsx => ShortVideoRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind21Renderer.tsx => VideoRenderer.tsx} (100%) rename src/components/nostr/kinds/{Kind9735Renderer.tsx => ZapReceiptRenderer.tsx} (100%) create mode 100644 src/components/ui/Label.tsx create mode 100644 src/lib/nip-c0-helpers.ts create mode 100644 src/styles/prism-theme.css diff --git a/claudedocs/kind-1337-code-snippets.md b/claudedocs/kind-1337-code-snippets.md new file mode 100644 index 0000000..a37f17e --- /dev/null +++ b/claudedocs/kind-1337-code-snippets.md @@ -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 `
` 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 `
` 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
diff --git a/package-lock.json b/package-lock.json
index 9b878a0..0ae15ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 5573605..cdd58d1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/CodeCopyButton.tsx b/src/components/CodeCopyButton.tsx
new file mode 100644
index 0000000..c6faefe
--- /dev/null
+++ b/src/components/CodeCopyButton.tsx
@@ -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 (
+    
+  );
+}
diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx
index be8dba1..08b1312 100644
--- a/src/components/DynamicWindowTitle.tsx
+++ b/src/components/DynamicWindowTitle.tsx
@@ -222,7 +222,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
         
         {getEventDisplayTitle(event, false)}
          - 
-        
+        
       
     );
   }, [appId, event]);
diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx
index b3ec3ee..10a075e 100644
--- a/src/components/EventDetailViewer.tsx
+++ b/src/components/EventDetailViewer.tsx
@@ -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) {
           
         ) : event.kind === kinds.Contacts ? (
           
+        ) : event.kind === 1337 ? (
+          
         ) : event.kind === 1617 ? (
           
         ) : event.kind === 1618 ? (
diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx
index 9641f3a..c3d4a54 100644
--- a/src/components/JsonViewer.tsx
+++ b/src/components/JsonViewer.tsx
@@ -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 (
     
-      
+      
         
           {title}
         
-        
-
-            
-            {jsonString}
-          
+
+ +
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index be84fc0..c1dc882 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -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) {
-
-              
-              {JSON.stringify(filter, null, 2)}
-            
+ + handleCopy(JSON.stringify(filter, null, 2))} + copied={copied} + label="Copy query JSON" + />
diff --git a/src/components/SyntaxHighlight.tsx b/src/components/SyntaxHighlight.tsx new file mode 100644 index 0000000..9ca5f92 --- /dev/null +++ b/src/components/SyntaxHighlight.tsx @@ -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 + * + * ``` + */ +export function SyntaxHighlight({ + code, + language, + className = "", + showLineNumbers = false, +}: SyntaxHighlightProps) { + const codeRef = useRef(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 ( +
+      
+        {code}
+      
+    
+ ); +} diff --git a/src/components/nostr/kinds/Kind30023DetailRenderer.tsx b/src/components/nostr/kinds/ArticleDetailRenderer.tsx similarity index 90% rename from src/components/nostr/kinds/Kind30023DetailRenderer.tsx rename to src/components/nostr/kinds/ArticleDetailRenderer.tsx index 6e4f01e..8fe7fe8 100644 --- a/src/components/nostr/kinds/Kind30023DetailRenderer.tsx +++ b/src/components/nostr/kinds/ArticleDetailRenderer.tsx @@ -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 }) => (

), - code: ({ ...props }: any) => ( - - ), + 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 ( + + {children} + + ); + } + + // Block code with syntax highlighting + return ( + + ); + }, blockquote: ({ ...props }) => (

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 = { + 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 ( +
+ {/* Header */} +

{name || "Code Snippet"}

+ + {/* Description */} + {description &&

{description}

} + + {/* Metadata Section */} +
+ {language && ( +
+

Language

+ {language} +
+ )} + {extension && ( +
+

Extension

+ .{extension} +
+ )} + {/* Runtime */} + {runtime && ( +
+

Runtime

+ {runtime} +
+ )} + + {/* Licenses */} + {licenses.length > 0 && ( +
+

License

+ {licenses.join(", ")} +
+ )} + + {/* Dependencies */} + {dependencies.length > 0 && ( +
+

Dependencies

+
+ {dependencies.map((dep, idx) => ( + + ))} +
+
+ )} + + {/* Repository */} + {repo && ( +
+

Repository

+ {repo.type === "url" ? ( + + {repo.value} + + + ) : ( + + )} +
+ )} +
+ + {/* Code Section */} +
+ {normalizedLanguage ? ( + <> + + + + ) : ( +
+            
+            {event.content}
+          
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/CodeSnippetRenderer.tsx b/src/components/nostr/kinds/CodeSnippetRenderer.tsx new file mode 100644 index 0000000..e98d08b --- /dev/null +++ b/src/components/nostr/kinds/CodeSnippetRenderer.tsx @@ -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 ( + +
+
+ {/* Title */} + + {name || "Code Snippet"} + + + {/* Language Badge */} + {language && ( +
+ +
+ )} +
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Code Preview */} +
+
+            
+              {previewLines.join("\n")}
+              {hasMore && "\n..."}
+            
+          
+
+
+
+ ); +} diff --git a/src/components/nostr/kinds/Kind3Renderer.tsx b/src/components/nostr/kinds/ContactListRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind3Renderer.tsx rename to src/components/nostr/kinds/ContactListRenderer.tsx diff --git a/src/components/nostr/kinds/Kind1063Renderer.tsx b/src/components/nostr/kinds/FileMetadataRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind1063Renderer.tsx rename to src/components/nostr/kinds/FileMetadataRenderer.tsx diff --git a/src/components/nostr/kinds/Kind9802DetailRenderer.tsx b/src/components/nostr/kinds/HighlightDetailRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind9802DetailRenderer.tsx rename to src/components/nostr/kinds/HighlightDetailRenderer.tsx diff --git a/src/components/nostr/kinds/Kind9802Renderer.tsx b/src/components/nostr/kinds/HighlightRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind9802Renderer.tsx rename to src/components/nostr/kinds/HighlightRenderer.tsx diff --git a/src/components/nostr/kinds/IssueDetailRenderer.tsx b/src/components/nostr/kinds/IssueDetailRenderer.tsx index 0038bfd..fc6cf04 100644 --- a/src/components/nostr/kinds/IssueDetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -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 }) {
{labels.map((label, idx) => ( - + + ))}
)} @@ -282,12 +281,32 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) { p: ({ ...props }) => (

), - code: ({ ...props }: any) => ( - - ), + 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 ( + + {children} + + ); + } + + // Block code with syntax highlighting + return ( + + ); + }, blockquote: ({ ...props }) => (


, }} > - {event.content.replace(/\\n/g, '\n')} + {event.content.replace(/\\n/g, "\n")} ) : ( diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index 6349689..53fa086 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -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) => ( - - {label} - + ))} )} diff --git a/src/components/nostr/kinds/Kind1Renderer.tsx b/src/components/nostr/kinds/NoteRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind1Renderer.tsx rename to src/components/nostr/kinds/NoteRenderer.tsx diff --git a/src/components/nostr/kinds/PatchDetailRenderer.tsx b/src/components/nostr/kinds/PatchDetailRenderer.tsx index b45b8cc..3e85f0f 100644 --- a/src/components/nostr/kinds/PatchDetailRenderer.tsx +++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx @@ -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 }) {

Patch

-
-              {event.content}
-            
- + + copy(event.content)} + copied={copied} + label="Copy patch" + />
)} diff --git a/src/components/nostr/kinds/Kind20Renderer.tsx b/src/components/nostr/kinds/PictureRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind20Renderer.tsx rename to src/components/nostr/kinds/PictureRenderer.tsx diff --git a/src/components/nostr/kinds/Kind0DetailRenderer.tsx b/src/components/nostr/kinds/ProfileDetailRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind0DetailRenderer.tsx rename to src/components/nostr/kinds/ProfileDetailRenderer.tsx diff --git a/src/components/nostr/kinds/Kind0Renderer.tsx b/src/components/nostr/kinds/ProfileRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind0Renderer.tsx rename to src/components/nostr/kinds/ProfileRenderer.tsx diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx index 9246003..a29491c 100644 --- a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx @@ -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 }) {
{labels.map((label, idx) => ( - + + ))}
)} @@ -396,12 +395,32 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) { p: ({ ...props }) => (

), - code: ({ ...props }: any) => ( - - ), + 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 ( + + {children} + + ); + } + + // Block code with syntax highlighting + return ( + + ); + }, blockquote: ({ ...props }) => (


, }} > - {event.content.replace(/\\n/g, '\n')} + {event.content.replace(/\\n/g, "\n")} diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index 91ecb04..c6ba33f 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -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 && (
{labels.map((label, idx) => ( - - {label} - + ))}
)} diff --git a/src/components/nostr/kinds/Kind7Renderer.tsx b/src/components/nostr/kinds/ReactionRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind7Renderer.tsx rename to src/components/nostr/kinds/ReactionRenderer.tsx diff --git a/src/components/nostr/kinds/Kind10002DetailRenderer.tsx b/src/components/nostr/kinds/RelayListDetailRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind10002DetailRenderer.tsx rename to src/components/nostr/kinds/RelayListDetailRenderer.tsx diff --git a/src/components/nostr/kinds/Kind10002Renderer.tsx b/src/components/nostr/kinds/RelayListRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind10002Renderer.tsx rename to src/components/nostr/kinds/RelayListRenderer.tsx diff --git a/src/components/nostr/kinds/Kind22Renderer.tsx b/src/components/nostr/kinds/ShortVideoRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind22Renderer.tsx rename to src/components/nostr/kinds/ShortVideoRenderer.tsx diff --git a/src/components/nostr/kinds/Kind21Renderer.tsx b/src/components/nostr/kinds/VideoRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind21Renderer.tsx rename to src/components/nostr/kinds/VideoRenderer.tsx diff --git a/src/components/nostr/kinds/Kind9735Renderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx similarity index 100% rename from src/components/nostr/kinds/Kind9735Renderer.tsx rename to src/components/nostr/kinds/ZapReceiptRenderer.tsx diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 887eba0..a7e8767 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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"; diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx new file mode 100644 index 0000000..c775e90 --- /dev/null +++ b/src/components/ui/Label.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/index.css b/src/index.css index 4c25f0b..0c01c30 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,6 @@ +/* Prism syntax highlighting theme */ +@import './styles/prism-theme.css'; + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/src/lib/event-title.ts b/src/lib/event-title.ts index 3af6e20..c8824a3 100644 --- a/src/lib/event-title.ts +++ b/src/lib/event-title.ts @@ -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; diff --git a/src/lib/nip-c0-helpers.ts b/src/lib/nip-c0-helpers.ts new file mode 100644 index 0000000..00ad532 --- /dev/null +++ b/src/lib/nip-c0-helpers.ts @@ -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 }; +} diff --git a/src/styles/prism-theme.css b/src/styles/prism-theme.css new file mode 100644 index 0000000..a8fa15c --- /dev/null +++ b/src/styles/prism-theme.css @@ -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)); +}