You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
309 lines
8.3 KiB
309 lines
8.3 KiB
<script lang="ts"> |
|
/** |
|
* Generic documentation viewer |
|
* Handles markdown, asciidoc, and kind 30040 publication indexes |
|
*/ |
|
|
|
import { onMount } from 'svelte'; |
|
import PublicationIndexViewer from '$lib/components/PublicationIndexViewer.svelte'; |
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
|
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 { |
|
if (!html || !filePath) return html; |
|
|
|
// Get the directory of the current file |
|
const fileDir = filePath.includes('/') |
|
? filePath.substring(0, filePath.lastIndexOf('/')) |
|
: ''; |
|
|
|
// Rewrite relative image paths |
|
return html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { |
|
// 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/')) { |
|
return match; |
|
} |
|
|
|
// Resolve relative path |
|
let imagePath: string; |
|
if (src.startsWith('/')) { |
|
// Absolute path from repo root |
|
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 |
|
const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; |
|
|
|
return `<img${before} src="${apiUrl}"${after}>`; |
|
}); |
|
} |
|
|
|
interface Props { |
|
content?: string; |
|
contentType?: 'markdown' | 'asciidoc' | 'text' | '30040'; |
|
indexEvent?: NostrEvent | null; |
|
relays?: string[]; |
|
onItemClick?: ((item: any) => void) | null; |
|
npub?: string; |
|
repo?: string; |
|
currentBranch?: string; |
|
filePath?: string | null; |
|
} |
|
|
|
let { |
|
content = '', |
|
contentType = 'text', |
|
indexEvent = null, |
|
relays = [], |
|
onItemClick = null, |
|
npub = '', |
|
repo = '', |
|
currentBranch = 'HEAD', |
|
filePath = null |
|
}: Props = $props(); |
|
|
|
let renderedContent = $state(''); |
|
let loading = $state(false); |
|
let error = $state<string | null>(null); |
|
|
|
$effect(() => { |
|
// Explicitly track both content and contentType |
|
const currentContent = content; |
|
const currentContentType = contentType; |
|
|
|
if (currentContentType === '30040' && indexEvent) { |
|
// Publication index - handled by PublicationIndexViewer |
|
return; |
|
} |
|
|
|
if (currentContent) { |
|
// Re-render when content or contentType changes |
|
doRenderContent(); |
|
} |
|
}); |
|
|
|
async function doRenderContent() { |
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
logger.operation('Rendering content', { contentType, length: content.length, preview: content.substring(0, 100) }); |
|
|
|
// Use the shared content renderer utility |
|
// contentType '30040' is handled separately by PublicationIndexViewer |
|
if (contentType === '30040') { |
|
// Should not reach here, but handle gracefully |
|
renderedContent = ''; |
|
} else { |
|
renderedContent = await renderContent(content, contentType as 'markdown' | 'asciidoc' | 'text'); |
|
|
|
logger.debug({ contentType, renderedLength: renderedContent.length, preview: renderedContent.substring(0, 200) }, 'Content rendered'); |
|
|
|
// Rewrite image paths to use API endpoint |
|
if (npub && repo && filePath) { |
|
renderedContent = rewriteImagePaths(renderedContent, filePath, npub, repo, currentBranch); |
|
} |
|
} |
|
|
|
logger.operation('Content rendered', { contentType, renderedLength: renderedContent.length }); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to render content'; |
|
logger.error({ error: err, contentType, contentPreview: content.substring(0, 100) }, 'Error rendering content'); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
function handleItemClick(item: any) { |
|
if (onItemClick) { |
|
onItemClick(item); |
|
} else { |
|
logger.debug({ item }, 'Publication index item clicked'); |
|
// Could navigate to item URL or emit event |
|
if (item.url) { |
|
window.open(item.url, '_blank'); |
|
} |
|
} |
|
} |
|
</script> |
|
|
|
<div class="docs-viewer"> |
|
{#if loading} |
|
<div class="loading">Rendering content...</div> |
|
{:else if error} |
|
<div class="error">{error}</div> |
|
{:else if contentType === '30040' && indexEvent} |
|
<PublicationIndexViewer |
|
{indexEvent} |
|
{relays} |
|
onItemClick={handleItemClick} |
|
/> |
|
{:else if renderedContent} |
|
<div class="rendered-content" class:markdown={contentType === 'markdown'} class:asciidoc={contentType === 'asciidoc'}> |
|
<NostrHtmlRenderer html={renderedContent} /> |
|
</div> |
|
{:else} |
|
<div class="empty">No content to display</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.docs-viewer { |
|
width: 100%; |
|
max-width: 100%; |
|
box-sizing: border-box; |
|
} |
|
|
|
.loading, .error, .empty { |
|
padding: 2rem; |
|
text-align: center; |
|
color: var(--text-secondary); |
|
} |
|
|
|
.error { |
|
color: var(--accent-error); |
|
} |
|
|
|
.rendered-content { |
|
width: 100%; |
|
max-width: 100%; |
|
box-sizing: border-box; |
|
line-height: 1.6; |
|
overflow-wrap: break-word; |
|
word-wrap: break-word; |
|
} |
|
|
|
.rendered-content :global(img) { |
|
max-width: 100%; |
|
height: auto; |
|
display: block; |
|
} |
|
|
|
.rendered-content :global(h1), |
|
.rendered-content :global(h2), |
|
.rendered-content :global(h3), |
|
.rendered-content :global(h4), |
|
.rendered-content :global(h5), |
|
.rendered-content :global(h6) { |
|
margin-top: 2rem; |
|
margin-bottom: 1rem; |
|
font-weight: 600; |
|
} |
|
|
|
.rendered-content :global(h1) { |
|
font-size: 2rem; |
|
border-bottom: 2px solid var(--border-color); |
|
padding-bottom: 0.5rem; |
|
} |
|
|
|
.rendered-content :global(h2) { |
|
font-size: 1.5rem; |
|
} |
|
|
|
.rendered-content :global(h3) { |
|
font-size: 1.25rem; |
|
} |
|
|
|
.rendered-content :global(p) { |
|
margin: 1rem 0; |
|
} |
|
|
|
.rendered-content :global(code) { |
|
background: var(--bg-secondary); |
|
padding: 0.2rem 0.4rem; |
|
border-radius: 3px; |
|
font-family: monospace; |
|
font-size: 0.9em; |
|
} |
|
|
|
.rendered-content :global(pre) { |
|
background: var(--bg-secondary); |
|
padding: 1rem; |
|
border-radius: 4px; |
|
overflow-x: auto; |
|
margin: 1rem 0; |
|
max-width: 100%; |
|
box-sizing: border-box; |
|
} |
|
|
|
.rendered-content :global(pre code) { |
|
background: none; |
|
padding: 0; |
|
} |
|
|
|
.rendered-content :global(blockquote) { |
|
border-left: 4px solid var(--accent-color); |
|
padding-left: 1rem; |
|
margin: 1rem 0; |
|
color: var(--text-secondary); |
|
} |
|
|
|
.rendered-content :global(ul), |
|
.rendered-content :global(ol) { |
|
margin: 1rem 0; |
|
padding-left: 2rem; |
|
} |
|
|
|
.rendered-content :global(li) { |
|
margin: 0.5rem 0; |
|
} |
|
|
|
.rendered-content :global(a) { |
|
color: var(--accent-color); |
|
text-decoration: none; |
|
} |
|
|
|
.rendered-content :global(a:hover) { |
|
text-decoration: underline; |
|
} |
|
|
|
.rendered-content :global(table) { |
|
width: 100%; |
|
max-width: 100%; |
|
border-collapse: collapse; |
|
margin: 1rem 0; |
|
box-sizing: border-box; |
|
display: table; |
|
table-layout: auto; |
|
} |
|
|
|
.rendered-content :global(table) :global(td), |
|
.rendered-content :global(table) :global(th) { |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
} |
|
|
|
.rendered-content :global(th), |
|
.rendered-content :global(td) { |
|
border: 1px solid var(--border-color); |
|
padding: 0.5rem; |
|
text-align: left; |
|
} |
|
|
|
.rendered-content :global(th) { |
|
background: var(--bg-secondary); |
|
font-weight: 600; |
|
} |
|
</style>
|
|
|