diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 845a6e3..357e15a 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -103,3 +103,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112920,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 7"]],"content":"Signed commit: refactor 7","id":"80f54ac61390cfbc8f2496a162d7065c447033a2e085ab5886c8138e337e93f9","sig":"f64bd2c965eff3534ad68e245651c189dc925d9613dd85557d88af8c692361ade13ccdd9deb88ae07eb227aa002af99d525a7fdf6f29eca854b5a02882ef226f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772130529,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 8"]],"content":"Signed commit: refactor 8","id":"716cfe7b5d8b788e6e24092a6ad7e92de0b3d383c43a343f3c5bec4d2bbdd4b9","sig":"e80ed3d9d471bd6907e212edfd7cf3f6039fa80e4434c35f0591729515eaa98c7a8ac54f2ac6f7a2fefb7846de0e2f0a120543a0dbe862c47c7710a653189b0c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772131858,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 9"]],"content":"Signed commit: refactor 9","id":"0d92496bc69fe5a2005be0eba26653a729d358f9d9f227e1af01c330eb0c4387","sig":"9203dcc9cfb44804957d37d3b47079ac324bffb42e445a3a79130622e5e20fd10513d987f48edf195514ebf6cb136ba6c5992b39de21b8b47a086194e22cbaeb"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772136696,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 10"]],"content":"Signed commit: refactor 10","id":"7fb8d54e26ab59486f3b56d97e225ed02f893140025c03ccb95a991e523e6182","sig":"f4bb5a037c48d06854d9346ebf96aa9f65f11d3f96e23d08b7d38d0ebea9bab242ffa917239aa432d83a55f369586d66603f439f40eac8156aeaaf80737b81a1"} diff --git a/src/lib/components/NostrHtmlRenderer.svelte b/src/lib/components/NostrHtmlRenderer.svelte new file mode 100644 index 0000000..7db12c1 --- /dev/null +++ b/src/lib/components/NostrHtmlRenderer.svelte @@ -0,0 +1,203 @@ + + +
+ {#if loading} +
Loading nostr links...
+ {:else} + {#each htmlParts as part} + {#if part.type === 'html'} + {@html part.content} + {:else if part.type === 'profile' && part.pubkey} + + {:else if part.type === 'event' && part.event} + + {:else} + {part.content} + {/if} + {/each} + {/if} +
+ + diff --git a/src/lib/utils/nostr-links.ts b/src/lib/utils/nostr-links.ts index 7d35889..880fd8c 100644 --- a/src/lib/utils/nostr-links.ts +++ b/src/lib/utils/nostr-links.ts @@ -1,6 +1,9 @@ import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; +import { getUserRelays } from '$lib/services/nostr/user-relays.js'; +import { KIND } from '$lib/types/nostr.js'; export interface ParsedNostrLink { type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile'; @@ -21,6 +24,8 @@ export interface ProcessedContentPart { */ export function parseNostrLinks(content: string): ParsedNostrLink[] { const links: ParsedNostrLink[] = []; + // Match nostr: links - be more permissive with characters to handle HTML entities + // Note: bech32 uses base32 which is a-z, A-Z, 2-7, but we'll be more permissive const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g; let match; @@ -44,6 +49,10 @@ export function parseNostrLinks(content: string): ParsedNostrLink[] { }); } + if (links.length > 0) { + console.log('[parseNostrLinks] Found', links.length, 'nostr links:', links.map(l => l.value)); + } + return links; } @@ -60,7 +69,10 @@ export async function loadNostrLinks( if (links.length === 0) return; const eventIds: string[] = []; + const eventIdToRelays = new Map(); // Map event ID to relay hints + const eventIdToPubkey = new Map(); // Map event ID to author pubkey (from nevent) const aTags: string[] = []; + const aTagToRelays = new Map(); // Map a-tag to relay hints const npubs: string[] = []; for (const link of links) { @@ -68,15 +80,29 @@ export async function loadNostrLinks( if (link.type === 'nevent' || link.type === 'note1') { const decoded = nip19.decode(link.value.replace('nostr:', '')); if (decoded.type === 'nevent') { - eventIds.push(decoded.data.id); + const data = decoded.data as { id: string; pubkey?: string; relays?: string[] }; + eventIds.push(data.id); + // Store relay hints if available + if (data.relays && data.relays.length > 0) { + eventIdToRelays.set(data.id, data.relays); + } + // Store author pubkey if available (for fetching their relay list) + if (data.pubkey) { + eventIdToPubkey.set(data.id, data.pubkey); + } } else if (decoded.type === 'note') { eventIds.push(decoded.data as string); } } else if (link.type === 'naddr') { const decoded = nip19.decode(link.value.replace('nostr:', '')); if (decoded.type === 'naddr') { - const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + const data = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] }; + const aTag = `${data.kind}:${data.pubkey}:${data.identifier}`; aTags.push(aTag); + // Store relay hints if available + if (data.relays && data.relays.length > 0) { + aTagToRelays.set(aTag, data.relays); + } } } else if (link.type === 'npub' || link.type === 'nprofile') { const decoded = nip19.decode(link.value.replace('nostr:', '')); @@ -93,19 +119,104 @@ export async function loadNostrLinks( } } - // Fetch events + // Collect all unique relay hints + const relayHints = new Set(); + for (const relays of eventIdToRelays.values()) { + relays.forEach(r => relayHints.add(r)); + } + for (const relays of aTagToRelays.values()) { + relays.forEach(r => relayHints.add(r)); + } + + // Collect unique author pubkeys from nevent links + const authorPubkeys = new Set(); + for (const pubkey of eventIdToPubkey.values()) { + authorPubkeys.add(pubkey); + } + + // Fetch kind 10002 relay lists for all authors + const authorRelays = new Set(); + if (authorPubkeys.size > 0) { + console.log('[loadNostrLinks] Fetching relay lists for', authorPubkeys.size, 'authors'); + // Use a temporary client with search relays to fetch relay lists + const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); + for (const pubkey of authorPubkeys) { + try { + const userRelays = await getUserRelays(pubkey, searchClient); + // Add both inbox and outbox relays (author might have published to either) + userRelays.inbox.forEach(r => authorRelays.add(r)); + userRelays.outbox.forEach(r => authorRelays.add(r)); + console.log('[loadNostrLinks] Found', userRelays.inbox.length + userRelays.outbox.length, 'relays for author', pubkey.slice(0, 8) + '...'); + } catch (err) { + console.warn('[loadNostrLinks] Error fetching relay list for author', pubkey.slice(0, 8) + '...', err); + } + } + searchClient.close(); + } + + // Combine ALL relays: hints, author relays, search relays, and default relays + const allRelays = new Set(); + relayHints.forEach(r => allRelays.add(r)); + authorRelays.forEach(r => allRelays.add(r)); + DEFAULT_NOSTR_SEARCH_RELAYS.forEach(r => allRelays.add(r)); + // Also include default relays from the passed client + // Note: We can't access the client's relays directly, but we'll use the client itself as fallback + + // Fetch events - try with ALL combined relays first if (eventIds.length > 0) { try { - const events = await Promise.race([ - nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), - new Promise((resolve) => setTimeout(() => resolve([]), 10000)) - ]); + console.log('[loadNostrLinks] Fetching events:', eventIds); + console.log('[loadNostrLinks] Relay hints:', Array.from(relayHints).length); + console.log('[loadNostrLinks] Author relays:', Array.from(authorRelays).length); + console.log('[loadNostrLinks] Total unique relays:', allRelays.size); + + let events: NostrEvent[] = []; + const foundIds = new Set(); + + // Try fetching from ALL combined relays (hints + author relays + search relays) + const combinedRelays = Array.from(allRelays); + if (combinedRelays.length > 0) { + const combinedClient = new NostrClient(combinedRelays); + try { + const fetched = await Promise.race([ + combinedClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), + new Promise((resolve) => setTimeout(() => resolve([]), 20000)) + ]); + combinedClient.close(); + events.push(...fetched); + fetched.forEach(e => foundIds.add(e.id)); + console.log('[loadNostrLinks] Fetched', fetched.length, 'events from combined relays'); + } catch (err) { + console.warn('[loadNostrLinks] Error fetching from combined relays:', err); + combinedClient.close(); + } + } + + // If we didn't get all events, try default client as final fallback + const missingIds = eventIds.filter(id => !foundIds.has(id)); + if (missingIds.length > 0) { + console.log('[loadNostrLinks] Fetching', missingIds.length, 'missing events from default client'); + try { + const defaultEvents = await Promise.race([ + nostrClient.fetchEvents([{ ids: missingIds, limit: missingIds.length }]), + new Promise((resolve) => setTimeout(() => resolve([]), 15000)) + ]); + events.push(...defaultEvents); + defaultEvents.forEach(e => foundIds.add(e.id)); + console.log('[loadNostrLinks] Fetched', defaultEvents.length, 'additional events from default client'); + } catch (err) { + console.warn('[loadNostrLinks] Error fetching from default client:', err); + } + } + + console.log('[loadNostrLinks] Total fetched:', events.length, 'events out of', eventIds.length, 'requested'); for (const event of events) { eventCache.set(event.id, event); + console.log('[loadNostrLinks] Stored event:', event.id); } - } catch { - // Ignore fetch errors + } catch (err) { + console.error('[loadNostrLinks] Error fetching events:', err); } } @@ -118,10 +229,31 @@ export async function loadNostrLinks( const kind = parseInt(parts[0]); const pubkey = parts[1]; const dTag = parts[2]; - const events = await Promise.race([ - nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), - new Promise((resolve) => setTimeout(() => resolve([]), 10000)) - ]); + + let events: NostrEvent[] = []; + + // Try relay hints first if available + const hintRelays = aTagToRelays.get(aTag); + if (hintRelays && hintRelays.length > 0) { + const hintClient = new NostrClient(hintRelays); + try { + events = await Promise.race([ + hintClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + hintClient.close(); + } catch { + hintClient.close(); + } + } + + // Fallback to default relays if no events found + if (events.length === 0) { + events = await Promise.race([ + nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + } if (events.length > 0) { eventCache.set(events[0].id, events[0]); @@ -145,23 +277,39 @@ export function getEventFromNostrLink( if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { const decoded = nip19.decode(link.replace('nostr:', '')); if (decoded.type === 'nevent') { - return eventCache.get(decoded.data.id); + const data = decoded.data as { id: string; relays?: string[] }; + const eventId = data.id; + const event = eventCache.get(eventId); + if (!event) { + console.log('[getEventFromNostrLink] Event not found in cache:', eventId, 'Cache has:', Array.from(eventCache.keys())); + } + return event; } else if (decoded.type === 'note') { - return eventCache.get(decoded.data as string); + const eventId = decoded.data as string; + const event = eventCache.get(eventId); + if (!event) { + console.log('[getEventFromNostrLink] Note event not found in cache:', eventId); + } + return event; } } else if (link.startsWith('nostr:naddr1')) { const decoded = nip19.decode(link.replace('nostr:', '')); if (decoded.type === 'naddr') { - return Array.from(eventCache.values()).find(e => { + const data = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] }; + const event = Array.from(eventCache.values()).find(e => { const dTag = e.tags.find(t => t[0] === 'd')?.[1]; - return e.kind === decoded.data.kind && - e.pubkey === decoded.data.pubkey && - dTag === decoded.data.identifier; + return e.kind === data.kind && + e.pubkey === data.pubkey && + dTag === data.identifier; }); + if (!event) { + console.log('[getEventFromNostrLink] Naddr event not found in cache:', data); + } + return event; } } - } catch { - // Invalid link + } catch (err) { + console.error('[getEventFromNostrLink] Error decoding link:', link, err); } return undefined; } @@ -279,3 +427,54 @@ export function formatDiscussionTime(timestamp: number): string { return new Date(timestamp * 1000).toLocaleDateString(); } + +/** + * Process HTML content with nostr links into parts for rendering + * Similar to processContentWithNostrLinks but handles HTML properly + */ +export function processHtmlWithNostrLinks( + html: string, + eventCache: Map, + profileCache: Map +): Array<{ type: 'html' | 'event' | 'profile' | 'placeholder'; content: string; event?: NostrEvent; pubkey?: string }> { + const links = parseNostrLinks(html); + if (links.length === 0) { + return [{ type: 'html', content: html }]; + } + + const parts: Array<{ type: 'html' | 'event' | 'profile' | 'placeholder'; content: string; event?: NostrEvent; pubkey?: string }> = []; + let lastIndex = 0; + + for (const link of links) { + // Add HTML before link + if (link.start > lastIndex) { + const htmlPart = html.slice(lastIndex, link.start); + if (htmlPart) { + parts.push({ type: 'html', content: htmlPart }); + } + } + + // Add link + const event = getEventFromNostrLink(link.value, eventCache); + const pubkey = getPubkeyFromNostrLink(link.value, profileCache); + if (event) { + parts.push({ type: 'event', content: link.value, event }); + } else if (pubkey) { + parts.push({ type: 'profile', content: link.value, pubkey }); + } else { + parts.push({ type: 'placeholder', content: link.value }); + } + + lastIndex = link.end; + } + + // Add remaining HTML + if (lastIndex < html.length) { + const htmlPart = html.slice(lastIndex); + if (htmlPart) { + parts.push({ type: 'html', content: htmlPart }); + } + } + + return parts; +} diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 65a868f..cc4eed3 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -651,8 +651,13 @@ // README // Rewrite image paths in HTML to point to repository file API + // Uses the same pattern as DocsViewer which works correctly function rewriteImagePaths(html: string, filePath: string | null): string { - if (!html || !filePath) return html; + if (!html || !filePath) return html || ''; + if (typeof html !== 'string') { + console.error('[rewriteImagePaths] Invalid html parameter:', typeof html, html); + return ''; + } // Get the directory of the current file const fileDir = filePath.includes('/') @@ -663,17 +668,24 @@ // If repo is empty (no branches), use null and let API handle it const branch = state.git.currentBranch || state.git.defaultBranch || null; - // Rewrite relative image paths - return html.replace(/]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { - // Skip if it's already an absolute URL (http/https/data) + // Rewrite relative image paths - handle various img tag formats + // Match: , , , , etc. + // Pattern: ]*?)?\s+src\s*=\s*["']([^"']+)["']([^>]*)>/gi; + let matchCount = 0; + const result = html.replace(imgTagPattern, (match, beforeAttrs, src, afterAttrs) => { + matchCount++; + console.log('[rewriteImagePaths] Matched img tag:', match.substring(0, 100), 'src:', src); + // Skip if it's already an absolute URL (http/https/data) or already an API URL if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) { + console.log('[rewriteImagePaths] Skipping absolute URL:', src); return match; } // Resolve relative path let imagePath: string; if (src.startsWith('/')) { - // Absolute path from repo root + // Absolute path from repo root (remove leading slash) imagePath = src.substring(1); } else if (src.startsWith('./')) { // Relative to current file directory @@ -700,8 +712,24 @@ const ref = branch || 'HEAD'; const apiUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(ref)}`; - return ``; + console.log('[rewriteImagePaths] Rewriting:', src, '->', apiUrl); + // Reconstruct the img tag with the new src + // beforeAttrs might be undefined (if no attributes before src) or contain other attributes + const before = beforeAttrs ? beforeAttrs.trim() : ''; + return ``; }); + if (matchCount === 0) { + console.warn('[rewriteImagePaths] No img tags matched in HTML. HTML sample:', html.substring(0, 500)); + // Try alternative pattern in case the first one didn't match + const altPattern = /]+)>/gi; + const altMatches = html.match(altPattern); + if (altMatches) { + console.log('[rewriteImagePaths] Found img tags with alternative pattern:', altMatches); + } + } else { + console.log('[rewriteImagePaths] Processed', matchCount, 'image tag(s)'); + } + return result; } // Fork @@ -812,9 +840,18 @@ // Render markdown, asciidoc, or HTML files as HTML async function renderFileAsHtml(content: string, ext: string) { - await renderFileAsHtmlUtil(content, ext, state.files.currentFile, (html: string) => { - state.preview.file.html = html; - }); + const branch = state.git.currentBranch || state.git.defaultBranch || null; + await renderFileAsHtmlUtil( + content, + ext, + state.files.currentFile, + (html: string) => { + state.preview.file.html = html; + }, + state.npub, + state.repo, + branch + ); } // CSV and HTML utilities are now imported from utils/file-processing.ts @@ -2075,8 +2112,9 @@ {/if} - -
+ + {#if repoOwnerPubkeyDerived} +
{#if state.ui.activeTab === 'files'} -
+
+ {/if} diff --git a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte index 4f4273a..2541568 100644 --- a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte @@ -10,6 +10,7 @@ import { KIND } from '$lib/types/nostr.js'; import logger from '$lib/services/logger.js'; import { renderContent } from '../utils/content-renderer.js'; + import NostrHtmlRenderer from '$lib/components/NostrHtmlRenderer.svelte'; // Rewrite image paths in HTML to point to repository file API function rewriteImagePaths(html: string, filePath: string, npub: string, repo: string, branch: string): string { @@ -154,7 +155,7 @@ /> {:else if renderedContent}
- {@html renderedContent} +
{:else}
No content to display
diff --git a/src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte b/src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte index 54dfb92..a97210b 100644 --- a/src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte +++ b/src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte @@ -89,6 +89,7 @@ border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; + color: var(--text-primary); } .nav-back:hover { diff --git a/src/routes/repos/[npub]/[repo]/components/FilesTab.svelte b/src/routes/repos/[npub]/[repo]/components/FilesTab.svelte index 7b9c298..7438344 100644 --- a/src/routes/repos/[npub]/[repo]/components/FilesTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/FilesTab.svelte @@ -1,12 +1,13 @@