Browse Source

bug-fixes

Nostr-Signature: b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678
main
Silberengel 2 weeks ago
parent
commit
06838d1a52
  1. 1
      nostr/commit-signatures.jsonl
  2. 203
      src/lib/components/NostrHtmlRenderer.svelte
  3. 241
      src/lib/utils/nostr-links.ts
  4. 66
      src/routes/repos/[npub]/[repo]/+page.svelte
  5. 3
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  6. 1
      src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte
  7. 94
      src/routes/repos/[npub]/[repo]/components/FilesTab.svelte
  8. 51
      src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte
  9. 36
      src/routes/repos/[npub]/[repo]/components/PRsTab.svelte
  10. 36
      src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte
  11. 31
      src/routes/repos/[npub]/[repo]/services/file-operations.ts
  12. 76
      src/routes/repos/[npub]/[repo]/utils/file-processing.ts

1
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":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":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":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"}

203
src/lib/components/NostrHtmlRenderer.svelte

@ -0,0 +1,203 @@
<script lang="ts">
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import UserBadge from './UserBadge.svelte';
import {
loadNostrLinks,
processHtmlWithNostrLinks
} from '$lib/utils/nostr-links.js';
interface Props {
html: string;
relays?: string[];
}
let { html, relays = DEFAULT_NOSTR_RELAYS }: Props = $props();
// Create client once per relay set
let nostrClient = $state<NostrClient | null>(null);
let lastRelays = $state<string>('');
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // link -> pubkey hex
let loading = $state(true);
let lastHtml = $state<string>('');
let version = $state(0); // Force reactivity when Maps update
let loadingPromise: Promise<void> | null = null; // Track current loading promise
// Initialize client when relays change
$effect(() => {
const relaysKey = JSON.stringify(relays);
if (!nostrClient || lastRelays !== relaysKey) {
nostrClient?.close();
nostrClient = new NostrClient(relays);
lastRelays = relaysKey;
}
});
// Process HTML into parts - reactive so it re-computes when events/profiles load
// Include version in dependency to force re-computation when Maps update
const htmlParts = $derived.by(() => {
if (!html) return [];
// Access version to track it as a dependency
version; // eslint-disable-line @typescript-eslint/no-unused-expressions
return processHtmlWithNostrLinks(html, nostrLinkEvents, nostrLinkProfiles);
});
// Load events and profiles from nostr links
async function loadEventsAndProfiles() {
if (!html || !nostrClient) {
loading = false;
return;
}
const currentHtml = html;
// Skip if html hasn't changed and we already have data
if (currentHtml === lastHtml && nostrLinkEvents.size > 0) {
loading = false;
return;
}
// Skip if already loading the same HTML
if (currentHtml === lastHtml && loadingPromise) {
return;
}
// If HTML changed, cancel previous load
if (currentHtml !== lastHtml) {
loadingPromise = null;
}
loading = true;
console.log('[NostrHtmlRenderer] Processing HTML, length:', currentHtml.length);
// Clear existing maps and create new ones
const newEventCache = new Map<string, NostrEvent>();
const newProfileCache = new Map<string, string>();
// Create and assign loading promise immediately
loadingPromise = (async () => {
try {
await loadNostrLinks(currentHtml, nostrClient!, newEventCache, newProfileCache);
console.log('[NostrHtmlRenderer] After loadNostrLinks - events:', newEventCache.size, 'profiles:', newProfileCache.size);
// Only update if this is still the current HTML (prevent race conditions)
if (currentHtml === html) {
nostrLinkEvents = newEventCache;
nostrLinkProfiles = newProfileCache;
version++; // Increment to force derived to re-compute
// Set lastHtml only after successful load to prevent re-triggering
lastHtml = currentHtml;
}
} catch (err) {
console.error('[NostrHtmlRenderer] Error loading nostr links:', err);
} finally {
// Only clear loading state if HTML hasn't changed (prevent race conditions)
if (currentHtml === html) {
loading = false;
loadingPromise = null;
}
}
})();
await loadingPromise;
}
function formatTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
// Load when html changes - use a more specific dependency
$effect(() => {
// Track html value to detect changes
const currentHtml = html;
const hasClient = !!nostrClient;
if (currentHtml && hasClient) {
// Only load if HTML actually changed (not just if we don't have data)
// This prevents re-loading when component re-renders
if (currentHtml !== lastHtml) {
loadEventsAndProfiles();
}
} else if (!currentHtml) {
loading = false;
nostrLinkEvents = new Map();
nostrLinkProfiles = new Map();
lastHtml = '';
loadingPromise = null;
}
});
</script>
<div class="nostr-html-renderer">
{#if loading}
<div class="loading">Loading nostr links...</div>
{:else}
{#each htmlParts as part}
{#if part.type === 'html'}
{@html part.content}
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} />
<span class="nostr-link-event-time">
{formatTime(part.event.created_at)}
</span>
</div>
<div class="nostr-link-event-content">
{part.event.content || '(No content)'}
</div>
</div>
{:else}
<span class="nostr-link-placeholder">{part.content}</span>
{/if}
{/each}
{/if}
</div>
<style>
.nostr-html-renderer {
width: 100%;
}
.loading {
padding: 0.5rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.nostr-link-placeholder {
color: var(--text-secondary, #666);
font-style: italic;
}
.nostr-link-event {
margin: 0.5rem 0;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
}
.nostr-link-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.nostr-link-event-time {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.nostr-link-event-content {
color: var(--text-primary, #1a1a1a);
white-space: pre-wrap;
word-break: break-word;
}
</style>

241
src/lib/utils/nostr-links.ts

@ -1,6 +1,9 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.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 { export interface ParsedNostrLink {
type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile'; type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile';
@ -21,6 +24,8 @@ export interface ProcessedContentPart {
*/ */
export function parseNostrLinks(content: string): ParsedNostrLink[] { export function parseNostrLinks(content: string): ParsedNostrLink[] {
const links: 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; const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g;
let match; 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; return links;
} }
@ -60,7 +69,10 @@ export async function loadNostrLinks(
if (links.length === 0) return; if (links.length === 0) return;
const eventIds: string[] = []; const eventIds: string[] = [];
const eventIdToRelays = new Map<string, string[]>(); // Map event ID to relay hints
const eventIdToPubkey = new Map<string, string>(); // Map event ID to author pubkey (from nevent)
const aTags: string[] = []; const aTags: string[] = [];
const aTagToRelays = new Map<string, string[]>(); // Map a-tag to relay hints
const npubs: string[] = []; const npubs: string[] = [];
for (const link of links) { for (const link of links) {
@ -68,15 +80,29 @@ export async function loadNostrLinks(
if (link.type === 'nevent' || link.type === 'note1') { if (link.type === 'nevent' || link.type === 'note1') {
const decoded = nip19.decode(link.value.replace('nostr:', '')); const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'nevent') { 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') { } else if (decoded.type === 'note') {
eventIds.push(decoded.data as string); eventIds.push(decoded.data as string);
} }
} else if (link.type === 'naddr') { } else if (link.type === 'naddr') {
const decoded = nip19.decode(link.value.replace('nostr:', '')); const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'naddr') { 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); 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') { } else if (link.type === 'npub' || link.type === 'nprofile') {
const decoded = nip19.decode(link.value.replace('nostr:', '')); 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<string>();
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<string>();
for (const pubkey of eventIdToPubkey.values()) {
authorPubkeys.add(pubkey);
}
// Fetch kind 10002 relay lists for all authors
const authorRelays = new Set<string>();
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<string>();
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) { if (eventIds.length > 0) {
try { try {
const events = await Promise.race([ console.log('[loadNostrLinks] Fetching events:', eventIds);
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), console.log('[loadNostrLinks] Relay hints:', Array.from(relayHints).length);
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) console.log('[loadNostrLinks] Author relays:', Array.from(authorRelays).length);
]); console.log('[loadNostrLinks] Total unique relays:', allRelays.size);
let events: NostrEvent[] = [];
const foundIds = new Set<string>();
// 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<NostrEvent[]>((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<NostrEvent[]>((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) { for (const event of events) {
eventCache.set(event.id, event); eventCache.set(event.id, event);
console.log('[loadNostrLinks] Stored event:', event.id);
} }
} catch { } catch (err) {
// Ignore fetch errors console.error('[loadNostrLinks] Error fetching events:', err);
} }
} }
@ -118,10 +229,31 @@ export async function loadNostrLinks(
const kind = parseInt(parts[0]); const kind = parseInt(parts[0]);
const pubkey = parts[1]; const pubkey = parts[1];
const dTag = parts[2]; const dTag = parts[2];
const events = await Promise.race([
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), let events: NostrEvent[] = [];
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]); // 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<NostrEvent[]>((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<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
}
if (events.length > 0) { if (events.length > 0) {
eventCache.set(events[0].id, events[0]); eventCache.set(events[0].id, events[0]);
@ -145,23 +277,39 @@ export function getEventFromNostrLink(
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) {
const decoded = nip19.decode(link.replace('nostr:', '')); const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'nevent') { 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') { } 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')) { } else if (link.startsWith('nostr:naddr1')) {
const decoded = nip19.decode(link.replace('nostr:', '')); const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'naddr') { 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]; const dTag = e.tags.find(t => t[0] === 'd')?.[1];
return e.kind === decoded.data.kind && return e.kind === data.kind &&
e.pubkey === decoded.data.pubkey && e.pubkey === data.pubkey &&
dTag === decoded.data.identifier; dTag === data.identifier;
}); });
if (!event) {
console.log('[getEventFromNostrLink] Naddr event not found in cache:', data);
}
return event;
} }
} }
} catch { } catch (err) {
// Invalid link console.error('[getEventFromNostrLink] Error decoding link:', link, err);
} }
return undefined; return undefined;
} }
@ -279,3 +427,54 @@ export function formatDiscussionTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString(); 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<string, NostrEvent>,
profileCache: Map<string, string>
): 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;
}

66
src/routes/repos/[npub]/[repo]/+page.svelte

@ -651,8 +651,13 @@
// README // README
// Rewrite image paths in HTML to point to repository file API // 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 { 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 // Get the directory of the current file
const fileDir = filePath.includes('/') const fileDir = filePath.includes('/')
@ -663,17 +668,24 @@
// If repo is empty (no branches), use null and let API handle it // If repo is empty (no branches), use null and let API handle it
const branch = state.git.currentBranch || state.git.defaultBranch || null; const branch = state.git.currentBranch || state.git.defaultBranch || null;
// Rewrite relative image paths // Rewrite relative image paths - handle various img tag formats
return html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { // Match: <img src="...">, <img src='...'>, <img ... src="..." ...>, <img src="..." />, etc.
// Skip if it's already an absolute URL (http/https/data) // Pattern: <img followed by optional space/attributes, then src="..." or src='...'
const imgTagPattern = /<img(\s+[^>]*?)?\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/')) { if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) {
console.log('[rewriteImagePaths] Skipping absolute URL:', src);
return match; return match;
} }
// Resolve relative path // Resolve relative path
let imagePath: string; let imagePath: string;
if (src.startsWith('/')) { if (src.startsWith('/')) {
// Absolute path from repo root // Absolute path from repo root (remove leading slash)
imagePath = src.substring(1); imagePath = src.substring(1);
} else if (src.startsWith('./')) { } else if (src.startsWith('./')) {
// Relative to current file directory // Relative to current file directory
@ -700,8 +712,24 @@
const ref = branch || 'HEAD'; const ref = branch || 'HEAD';
const apiUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(ref)}`; const apiUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(ref)}`;
return `<img${before} src="${apiUrl}"${after}>`; 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 `<img${before ? ' ' + before : ''} src="${apiUrl}"${afterAttrs}>`;
}); });
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 = /<img([^>]+)>/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 // Fork
@ -812,9 +840,18 @@
// Render markdown, asciidoc, or HTML files as HTML // Render markdown, asciidoc, or HTML files as HTML
async function renderFileAsHtml(content: string, ext: string) { async function renderFileAsHtml(content: string, ext: string) {
await renderFileAsHtmlUtil(content, ext, state.files.currentFile, (html: string) => { const branch = state.git.currentBranch || state.git.defaultBranch || null;
state.preview.file.html = html; 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 // CSV and HTML utilities are now imported from utils/file-processing.ts
@ -2075,8 +2112,9 @@
</div> </div>
{/if} {/if}
<!-- Tabs --> <!-- Tabs - only show if we have repo data (header/clone section would be visible) -->
<div class="repo-layout"> {#if repoOwnerPubkeyDerived}
<div class="repo-layout">
<!-- Files Tab --> <!-- Files Tab -->
{#if state.ui.activeTab === 'files'} {#if state.ui.activeTab === 'files'}
<FilesTab <FilesTab
@ -2101,9 +2139,6 @@
state.files.hasChanges = content !== state.files.content; state.files.hasChanges = content !== state.files.content;
}} }}
isMaintainer={state.maintainers.isMaintainer} isMaintainer={state.maintainers.isMaintainer}
readmeContent={state.preview.readme.content || null}
readmePath={state.preview.readme.path || null}
readmeHtml={state.preview.readme.html}
showFilePreview={state.preview.file.showPreview} showFilePreview={state.preview.file.showPreview}
fileHtml={state.preview.file.html} fileHtml={state.preview.file.html}
highlightedFileContent={state.preview.file.highlightedContent} highlightedFileContent={state.preview.file.highlightedContent}
@ -2594,7 +2629,8 @@
<!-- Docs tab content is now handled by DocsTab component --> <!-- Docs tab content is now handled by DocsTab component -->
</div> </div>
{/if}
</main> </main>
<!-- Dialogs --> <!-- Dialogs -->

3
src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte

@ -10,6 +10,7 @@
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { renderContent } from '../utils/content-renderer.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 // Rewrite image paths in HTML to point to repository file API
function rewriteImagePaths(html: string, filePath: string, npub: string, repo: string, branch: string): string { function rewriteImagePaths(html: string, filePath: string, npub: string, repo: string, branch: string): string {
@ -154,7 +155,7 @@
/> />
{:else if renderedContent} {:else if renderedContent}
<div class="rendered-content" class:markdown={contentType === 'markdown'} class:asciidoc={contentType === 'asciidoc'}> <div class="rendered-content" class:markdown={contentType === 'markdown'} class:asciidoc={contentType === 'asciidoc'}>
{@html renderedContent} <NostrHtmlRenderer html={renderedContent} />
</div> </div>
{:else} {:else}
<div class="empty">No content to display</div> <div class="empty">No content to display</div>

1
src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte

@ -89,6 +89,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
color: var(--text-primary);
} }
.nav-back:hover { .nav-back:hover {

94
src/routes/repos/[npub]/[repo]/components/FilesTab.svelte

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
/** /**
* Files tab component * Files tab component
* Handles file browser, editor, and README display * Handles file browser and editor
*/ */
import TabLayout from './TabLayout.svelte'; import TabLayout from './TabLayout.svelte';
import FileBrowser from './FileBrowser.svelte'; import FileBrowser from './FileBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte';
import NostrHtmlRenderer from '$lib/components/NostrHtmlRenderer.svelte';
interface Props { interface Props {
files?: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>; files?: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>;
@ -24,9 +25,6 @@
onNavigateBack?: () => void; onNavigateBack?: () => void;
onContentChange?: (content: string) => void; onContentChange?: (content: string) => void;
isMaintainer?: boolean; isMaintainer?: boolean;
readmeContent?: string | null;
readmePath?: string | null;
readmeHtml?: string | null;
showFilePreview?: boolean; showFilePreview?: boolean;
fileHtml?: string | null; fileHtml?: string | null;
highlightedFileContent?: string | null; highlightedFileContent?: string | null;
@ -68,9 +66,6 @@
onNavigateBack = () => {}, onNavigateBack = () => {},
onContentChange = () => {}, onContentChange = () => {},
isMaintainer = false, isMaintainer = false,
readmeContent = null,
readmePath = null,
readmeHtml = null,
showFilePreview = false, showFilePreview = false,
fileHtml = null, fileHtml = null,
highlightedFileContent = null, highlightedFileContent = null,
@ -103,7 +98,7 @@
activeTab={activeTab} activeTab={activeTab}
tabs={tabs} tabs={tabs}
onTabChange={onTabChange} onTabChange={onTabChange}
title={currentFile ? `File: ${currentFile.split('/').pop()}` : (readmeContent ? 'README' : 'Files')} title={currentFile ? `File: ${currentFile.split('/').pop()}` : 'Files'}
> >
{#snippet leftPane()} {#snippet leftPane()}
<FileBrowser <FileBrowser
@ -117,36 +112,7 @@
{/snippet} {/snippet}
{#snippet rightPanel()} {#snippet rightPanel()}
{#if readmeContent && !currentFile} {#if currentFile}
<div class="readme-section">
<div class="readme-header">
<h3>README</h3>
<div class="readme-actions">
{#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())}
<button
onclick={onTogglePreview}
class="preview-toggle-button"
title={showFilePreview ? 'Show raw' : 'Show preview'}
>
{showFilePreview ? 'Raw' : 'Preview'}
</button>
{/if}
{#if readmePath}
<a href={`/api/repos/${readmePath}`} target="_blank" class="raw-link">View Raw</a>
{/if}
</div>
</div>
{#if showFilePreview && readmeHtml && readmeHtml.trim()}
<div class="readme-content markdown">
{@html readmeHtml}
</div>
{:else if readmeContent}
<div class="readme-content raw-content">
<pre><code class="hljs language-text">{readmeContent}</code></pre>
</div>
{/if}
</div>
{:else if currentFile}
<div class="file-editor"> <div class="file-editor">
<div class="editor-header"> <div class="editor-header">
<span class="file-path">{currentFile}</span> <span class="file-path">{currentFile}</span>
@ -237,7 +203,7 @@
</div> </div>
{:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())} {:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())}
<div class="file-preview markdown"> <div class="file-preview markdown">
{@html fileHtml} <NostrHtmlRenderer html={fileHtml} />
</div> </div>
{:else if highlightedFileContent} {:else if highlightedFileContent}
<div class="raw-content"> <div class="raw-content">
@ -259,13 +225,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if files.length === 0 && !readmeContent} {:else if files.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>This repo is empty and contains no files.</p> <p>This repo is empty and contains no files.</p>
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<p>Select a file to view or edit</p> <p>Select a file from the left to view it on the right</p>
</div> </div>
{/if} {/if}
{/snippet} {/snippet}
@ -399,13 +365,6 @@
min-width: 0; min-width: 0;
} }
.readme-content.raw-content {
max-width: 100%;
overflow-x: auto;
box-sizing: border-box;
width: 100%;
}
.editor-container { .editor-container {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -449,45 +408,6 @@
display: block; display: block;
} }
.readme-section {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.readme-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
width: 100%;
}
.readme-header h3 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
}
.readme-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.readme-content {
flex: 1;
min-height: 0;
overflow: auto;
width: 100%;
max-width: 100%;
padding: 1.5rem;
}
.file-editor .editor-actions { .file-editor .editor-actions {
display: flex !important; display: flex !important;
align-items: center; align-items: center;

51
src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte

@ -5,6 +5,8 @@
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js'; import { renderContent } from '../utils/content-renderer.js';
import NostrHtmlRenderer from '$lib/components/NostrHtmlRenderer.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
interface Props { interface Props {
issues: Array<{ issues: Array<{
@ -88,6 +90,7 @@
<div class="issue-detail-header"> <div class="issue-detail-header">
<h2>{item.subject}</h2> <h2>{item.subject}</h2>
<div class="issue-actions"> <div class="issue-actions">
<EventCopyButton eventId={item.id} kind={item.kind} pubkey={(item as any).pubkey} />
<select <select
value={currentStatus} value={currentStatus}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
@ -103,7 +106,7 @@
{#await contentPromise} {#await contentPromise}
<div class="loading">Rendering content...</div> <div class="loading">Rendering content...</div>
{:then html} {:then html}
{@html html} <NostrHtmlRenderer html={html} />
{:catch err} {:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div> <div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await} {/await}
@ -117,12 +120,15 @@
{#each issueReplies as reply} {#each issueReplies as reply}
{@const replyPromise = getRenderedContent(reply.content || '', reply.kind)} {@const replyPromise = getRenderedContent(reply.content || '', reply.kind)}
<div class="reply"> <div class="reply">
<div class="reply-author">{reply.author}</div> <div class="reply-header">
<div class="reply-author">{reply.author}</div>
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={(reply as any).pubkey} />
</div>
<div class="reply-content"> <div class="reply-content">
{#await replyPromise} {#await replyPromise}
<div class="loading">Rendering...</div> <div class="loading">Rendering...</div>
{:then html} {:then html}
{@html html} <NostrHtmlRenderer html={html} />
{:catch err} {:catch err}
{reply.content} {reply.content}
{/await} {/await}
@ -180,13 +186,44 @@
.issue-detail-header { .issue-detail-header {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.issue-detail-header h2 {
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
.issue-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.issue-detail-header {
flex-direction: column;
align-items: flex-start;
}
.issue-detail-header h2 {
width: 100%;
}
.issue-actions {
width: 100%;
justify-content: flex-start;
}
}
.issue-content { .issue-content {
margin: 1rem 0; margin: 1rem 0;
line-height: 1.6; line-height: 1.6;
@ -205,9 +242,15 @@
border-radius: 4px; border-radius: 4px;
} }
.reply-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.reply-author { .reply-author {
font-weight: 500; font-weight: 500;
margin-bottom: 0.5rem;
} }
.reply-content { .reply-content {

36
src/routes/repos/[npub]/[repo]/components/PRsTab.svelte

@ -5,6 +5,8 @@
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js'; import { renderContent } from '../utils/content-renderer.js';
import NostrHtmlRenderer from '$lib/components/NostrHtmlRenderer.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Props { interface Props {
@ -88,6 +90,7 @@
<div class="pr-detail-header"> <div class="pr-detail-header">
<h2>{item.subject}</h2> <h2>{item.subject}</h2>
<div class="pr-actions"> <div class="pr-actions">
<EventCopyButton eventId={item.id} kind={item.kind} pubkey={(item as any).pubkey} />
<select <select
value={currentStatus} value={currentStatus}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
@ -103,7 +106,7 @@
{#await contentPromise} {#await contentPromise}
<div class="loading">Rendering content...</div> <div class="loading">Rendering content...</div>
{:then html} {:then html}
{@html html} <NostrHtmlRenderer html={html} />
{:catch err} {:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div> <div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await} {/await}
@ -162,13 +165,44 @@
.pr-detail-header { .pr-detail-header {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.pr-detail-header h2 {
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
.pr-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.pr-detail-header {
flex-direction: column;
align-items: flex-start;
}
.pr-detail-header h2 {
width: 100%;
}
.pr-actions {
width: 100%;
justify-content: flex-start;
}
}
.pr-content { .pr-content {
margin: 1rem 0; margin: 1rem 0;
line-height: 1.6; line-height: 1.6;

36
src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte

@ -5,6 +5,8 @@
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js'; import { renderContent } from '../utils/content-renderer.js';
import NostrHtmlRenderer from '$lib/components/NostrHtmlRenderer.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
interface Props { interface Props {
patches: Array<{ patches: Array<{
@ -88,6 +90,7 @@
<div class="patch-detail-header"> <div class="patch-detail-header">
<h2>{item.subject}</h2> <h2>{item.subject}</h2>
<div class="patch-actions"> <div class="patch-actions">
<EventCopyButton eventId={item.id} kind={item.kind} pubkey={(item as any).pubkey} />
<select <select
value={currentStatus} value={currentStatus}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
@ -113,7 +116,7 @@
{#await contentPromise} {#await contentPromise}
<div class="loading">Rendering content...</div> <div class="loading">Rendering content...</div>
{:then html} {:then html}
{@html html} <NostrHtmlRenderer html={html} />
{:catch err} {:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div> <div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await} {/await}
@ -166,13 +169,44 @@
.patch-detail-header { .patch-detail-header {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.patch-detail-header h2 {
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
.patch-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.patch-detail-header {
flex-direction: column;
align-items: flex-start;
}
.patch-detail-header h2 {
width: 100%;
}
.patch-actions {
width: 100%;
justify-content: flex-start;
}
}
.patch-content { .patch-content {
margin: 1rem 0; margin: 1rem 0;
line-height: 1.6; line-height: 1.6;

31
src/routes/repos/[npub]/[repo]/services/file-operations.ts

@ -284,7 +284,8 @@ export async function loadReadme(
// Reset preview mode for README // Reset preview mode for README
state.preview.file.showPreview = true; state.preview.file.showPreview = true;
state.preview.readme.html = ''; // DON'T reset readme.html here - keep existing HTML until new one is ready
// This prevents the component from falling back to DocsViewer with raw markdown
// Render markdown or asciidoc if needed // Render markdown or asciidoc if needed
if (state.preview.readme.content) { if (state.preview.readme.content) {
@ -315,8 +316,27 @@ export async function loadReadme(
}); });
let rendered = md.render(state.preview.readme.content); let rendered = md.render(state.preview.readme.content);
// Debug: check for image tags before rewrite
const imgBefore = rendered.match(/<img[^>]*>/gi);
if (imgBefore) {
console.log('[README] Images before rewrite:', imgBefore);
}
// Rewrite image paths to point to repository API // Rewrite image paths to point to repository API
const beforeRewrite = rendered;
rendered = rewriteImagePaths(rendered, state.preview.readme.path); rendered = rewriteImagePaths(rendered, state.preview.readme.path);
// Debug: check for image tags after rewrite
const imgAfter = rendered.match(/<img[^>]*>/gi);
if (imgAfter) {
console.log('[README] Images after rewrite:', imgAfter);
}
if (beforeRewrite === rendered) {
console.warn('[README] rewriteImagePaths did not change HTML - images may not be rewritten');
}
// Safety check - ensure rendered is still a string
if (!rendered || typeof rendered !== 'string') {
console.error('[README] rewriteImagePaths returned invalid value, using original');
rendered = md.render(state.preview.readme.content); // Fallback to original
}
state.preview.readme.html = rendered; state.preview.readme.html = rendered;
console.log('[README] Markdown rendered successfully, HTML length:', state.preview.readme.html.length); console.log('[README] Markdown rendered successfully, HTML length:', state.preview.readme.html.length);
} catch (err) { } catch (err) {
@ -349,7 +369,16 @@ export async function loadReadme(
} else { } else {
state.preview.readme.html = ''; state.preview.readme.html = '';
} }
} else {
// No content available, clear HTML
state.preview.readme.html = '';
} }
} else {
// README not found, clear state
state.preview.readme.content = null;
state.preview.readme.path = null;
state.preview.readme.html = '';
state.preview.readme.isMarkdown = false;
} }
} catch (err) { } catch (err) {
console.error('Error loading README:', err); console.error('Error loading README:', err);

76
src/routes/repos/[npub]/[repo]/utils/file-processing.ts

@ -124,25 +124,66 @@ export function isImageFileType(ext: string): boolean {
/** /**
* Rewrite image paths in HTML to be relative to file path * Rewrite image paths in HTML to be relative to file path
*/ */
export function rewriteImagePaths(html: string, filePath: string | null): string { export function rewriteImagePaths(
html: string,
filePath: string | null,
npub?: string,
repo?: string,
branch?: string | null
): string {
if (!filePath || !html) return html; if (!filePath || !html) return html;
// Get directory path (remove filename) // Get directory path (remove filename)
const dirPath = filePath.split('/').slice(0, -1).join('/'); const fileDir = filePath.includes('/')
const basePath = dirPath ? `/${dirPath}/` : '/'; ? filePath.substring(0, filePath.lastIndexOf('/'))
: '';
// Rewrite image paths in HTML to point to repository file API
// Match: <img src="...">, <img src='...'>, <img ... src="..." ...>, etc.
const imgTagPattern = /<img(\s+[^>]*?)?\s+src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
// Rewrite relative image paths return html.replace(imgTagPattern, (match, beforeAttrs, src, afterAttrs) => {
// Match: src="image.png" or src='image.png' or src=image.png // Skip if it's already an absolute URL (http/https/data) or already an API URL
html = html.replace(/src=["']([^"']+)["']/g, (match, path) => { if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) {
// Skip absolute URLs and data URLs
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) {
return match; return match;
} }
// Make path relative to file directory
return `src="${basePath}${path}"`; // Resolve relative path
let imagePath: string;
if (src.startsWith('/')) {
// Absolute path from repo root (remove leading slash)
imagePath = src.substring(1);
} else if (src.startsWith('./')) {
// Relative to current file directory
imagePath = fileDir ? `${fileDir}/${src.substring(2)}` : src.substring(2);
} else {
// Relative to current file directory
imagePath = fileDir ? `${fileDir}/${src}` : src;
}
// Normalize path (remove .. and .)
const pathParts = imagePath.split('/').filter(p => p !== '.' && p !== '');
const normalizedPath: string[] = [];
for (const part of pathParts) {
if (part === '..') {
normalizedPath.pop();
} else {
normalizedPath.push(part);
}
}
imagePath = normalizedPath.join('/');
// Build API URL if npub, repo, and branch are provided
if (npub && repo) {
const ref = branch || 'HEAD';
const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(ref)}`;
const before = beforeAttrs ? beforeAttrs.trim() : '';
return `<img${before ? ' ' + before : ''} src="${apiUrl}"${afterAttrs}>`;
}
// Fallback: return original match if we don't have npub/repo
return match;
}); });
return html;
} }
/** /**
@ -289,7 +330,10 @@ export async function renderFileAsHtml(
content: string, content: string,
ext: string, ext: string,
filePath: string | null, filePath: string | null,
setHtml: (html: string) => void setHtml: (html: string) => void,
npub?: string,
repo?: string,
branch?: string | null
): Promise<void> { ): Promise<void> {
try { try {
const lowerExt = ext.toLowerCase(); const lowerExt = ext.toLowerCase();
@ -319,7 +363,7 @@ export async function renderFileAsHtml(
}); });
let rendered = md.render(content); let rendered = md.render(content);
rendered = rewriteImagePaths(rendered, filePath); rendered = rewriteImagePaths(rendered, filePath, npub, repo, branch);
setHtml(rendered); setHtml(rendered);
} else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { } else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') {
// Render asciidoc // Render asciidoc
@ -332,12 +376,12 @@ export async function renderFileAsHtml(
} }
}); });
let rendered = typeof converted === 'string' ? converted : String(converted); let rendered = typeof converted === 'string' ? converted : String(converted);
rendered = rewriteImagePaths(rendered, filePath); rendered = rewriteImagePaths(rendered, filePath, npub, repo, branch);
setHtml(rendered); setHtml(rendered);
} else if (lowerExt === 'html' || lowerExt === 'htm') { } else if (lowerExt === 'html' || lowerExt === 'htm') {
// HTML files - rewrite image paths // HTML files - rewrite image paths
let rendered = content; let rendered = content;
rendered = rewriteImagePaths(rendered, filePath); rendered = rewriteImagePaths(rendered, filePath, npub, repo, branch);
setHtml(rendered); setHtml(rendered);
} else if (lowerExt === 'csv') { } else if (lowerExt === 'csv') {
// Parse CSV and render as HTML table // Parse CSV and render as HTML table

Loading…
Cancel
Save