Browse Source

render videos and audio inline in kind 1

imwald
Silberengel 5 months ago
parent
commit
63c02d3bdb
  1. 120
      src/components/ArticleExportMenu/ArticleExportMenu.tsx
  2. 16
      src/components/Note/LongFormArticle/NostrNode.tsx
  3. 304
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 61
      src/components/Note/MarkdownArticle/remarkHashtags.ts
  5. 166
      src/components/Note/PublicationIndex/PublicationIndex.tsx

120
src/components/ArticleExportMenu/ArticleExportMenu.tsx

@ -6,7 +6,6 @@ import { @@ -6,7 +6,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreVertical, FileDown } from 'lucide-react'
import { contentParserService } from '@/services/content-parser.service'
import logger from '@/lib/logger'
import { Event } from 'nostr-tools'
@ -16,85 +15,13 @@ interface ArticleExportMenuProps { @@ -16,85 +15,13 @@ interface ArticleExportMenuProps {
}
export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) {
const exportArticle = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => {
const exportArticle = async () => {
try {
const content = event.content
const filename = `${title}.${format}`
const filename = `${title}.adoc`
let blob: Blob = new Blob([''])
if (format === 'adoc') {
// Export raw AsciiDoc content
blob = new Blob([content], { type: 'text/plain' })
} else if (format === 'html') {
// Parse the AsciiDoc content to HTML
const parsedContent = await contentParserService.parseContent(content, {
eventKind: event.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
</style>
</head>
<body>
<article>
<h1>${title}</h1>
${parsedContent.html}
</article>
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else if (format === 'latex') {
// Basic LaTeX conversion
let processedContent = content.replace(/^= (.+)$/gm, '\\section{$1}')
processedContent = processedContent.replace(/^== (.+)$/gm, '\\subsection{$1}')
processedContent = processedContent.replace(/^=== (.+)$/gm, '\\subsubsection{$1}')
blob = new Blob([processedContent], { type: 'text/plain' })
} else if (format === 'pdf' || format === 'epub') {
// Parse the AsciiDoc content to HTML using the content parser
const parsedContent = await contentParserService.parseContent(content, {
eventKind: event.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
</style>
</head>
<body>
<article>
<h1>${title}</h1>
${parsedContent.html}
</article>
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
}
// Export raw AsciiDoc content
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@ -105,7 +32,7 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro @@ -105,7 +32,7 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro
document.body.removeChild(a)
URL.revokeObjectURL(url)
logger.info(`[ArticleExportMenu] Exported article as ${format}`)
logger.info('[ArticleExportMenu] Exported article as .adoc')
} catch (error) {
logger.error('[ArticleExportMenu] Error exporting article:', error)
alert('Failed to export article. Please try again.')
@ -119,41 +46,10 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro @@ -119,41 +46,10 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()} className="w-56">
<DropdownMenuItem onClick={() => exportArticle('html')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as HTML</div>
<div className="text-xs text-muted-foreground">Ready to view in browser</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('adoc')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as AsciiDoc</div>
<div className="text-xs text-muted-foreground">Raw .adoc file</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('pdf')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as PDF</div>
<div className="text-xs text-muted-foreground">HTML - use browser Print to PDF</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('epub')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as EPUB</div>
<div className="text-xs text-muted-foreground">HTML - convert with Calibre</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('latex')}>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={exportArticle}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as LaTeX</div>
<div className="text-xs text-muted-foreground">Basic conversion</div>
</div>
Export as AsciiDoc
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

16
src/components/Note/LongFormArticle/NostrNode.tsx

@ -1,17 +1,21 @@ @@ -1,17 +1,21 @@
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
import { nip19 } from 'nostr-tools'
import { ComponentProps, useMemo } from 'react'
import { Components } from './types'
import { useMemo } from 'react'
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) {
interface NostrNodeProps {
rawText: string
bech32Id?: string
}
export default function NostrNode({ rawText, bech32Id }: NostrNodeProps) {
const { type, id } = useMemo(() => {
if (!bech32Id) return { type: 'invalid', id: '' }
try {
const { type } = nip19.decode(bech32Id)
if (type === 'npub' || type === 'nprofile') {
const decoded = nip19.decode(bech32Id)
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
return { type: 'mention', id: bech32Id }
}
if (type === 'nevent' || type === 'naddr' || type === 'note') {
if (decoded.type === 'nevent' || decoded.type === 'naddr' || decoded.type === 'note') {
return { type: 'note', id: bech32Id }
}
} catch (error) {

304
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,16 +1,18 @@ @@ -1,16 +1,18 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import ImageCarousel from '@/components/ImageCarousel/ImageCarousel'
import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link'
import { extractAllImagesFromEvent } from '@/lib/image-extraction'
import { getImetaInfosFromEvent } from '@/lib/event'
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import React, { useMemo, useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import NostrNode from './NostrNode'
import { remarkNostr } from './remarkNostr'
@ -35,6 +37,92 @@ export default function MarkdownArticle({ @@ -35,6 +37,92 @@ export default function MarkdownArticle({
const allImages = useMemo(() => extractAllImagesFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
// Extract, normalize, and deduplicate all media URLs (images, audio, video)
// from content, imeta tags, and image tags
const mediaUrls = useMemo(() => {
if (showImageGallery) return [] // Don't render inline for article content
const seenUrls = new Set<string>()
const mediaUrls: string[] = []
// Helper to normalize and add URL
const addUrl = (url: string) => {
if (!url) return
// Normalize URL by removing tracking parameters and cleaning it
let normalizedUrl = url
.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
try {
// Validate URL
const parsedUrl = new URL(normalizedUrl)
const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase()
// Check if it's a media file
const isMedia =
// Audio extensions
(extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) ||
// Video extensions
(extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) ||
// Image extensions
(extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension))
if (isMedia && !seenUrls.has(normalizedUrl)) {
mediaUrls.push(normalizedUrl)
seenUrls.add(normalizedUrl)
}
} catch {
// Invalid URL, skip
}
}
// 1. Extract from content - all URLs (need to match exactly what markdown will find)
const content = event.content || ''
// Match URLs that could be in markdown links or plain text
const urlMatches = content.match(/https?:\/\/[^\s<>"']+/g) || []
urlMatches.forEach(url => {
// Normalize the URL before adding
const normalized = url.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
addUrl(normalized)
})
// 2. Extract from imeta tags
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach(info => addUrl(info.url))
// 3. Extract from image tag
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addUrl(imageTag[1])
}
return mediaUrls
}, [event.content, event.tags, event.pubkey, showImageGallery])
// Initialize highlight.js for syntax highlighting
useEffect(() => {
const initHighlight = async () => {
@ -67,6 +155,24 @@ export default function MarkdownArticle({ @@ -67,6 +155,24 @@ export default function MarkdownArticle({
if (!href) {
return <span {...props} className="break-words" />
}
// Handle hashtag links (format: /notes?t=tag)
if (href.startsWith('/notes?t=') || href.startsWith('notes?t=')) {
// Normalize href to include leading slash if missing
const normalizedHref = href.startsWith('/') ? href : `/${href}`
return (
<SecondaryPageLink
to={normalizedHref}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline"
>
{children}
</SecondaryPageLink>
)
}
// Handle wikilinks - only handle if href looks like a wikilink format
// (we'll handle wikilinks in the text component below)
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
return (
<SecondaryPageLink
@ -87,6 +193,30 @@ export default function MarkdownArticle({ @@ -87,6 +193,30 @@ export default function MarkdownArticle({
</SecondaryPageLink>
)
}
// Check if this is a media URL that should be rendered inline (for non-article content)
// If so, don't render it as a link - it will be rendered as inline media below
if (!showImageGallery) {
// Normalize the href to match the normalized mediaUrls
const normalizedHref = href.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
if (mediaUrls.includes(normalizedHref)) {
return null
}
}
return (
<a
{...props}
@ -128,43 +258,74 @@ export default function MarkdownArticle({ @@ -128,43 +258,74 @@ export default function MarkdownArticle({
)
},
text: ({ children }) => {
// Handle hashtags in text
if (typeof children === 'string') {
const hashtagRegex = /#(\w+)/g
const parts = []
let lastIndex = 0
let match
while ((match = hashtagRegex.exec(children)) !== null) {
// Add text before the hashtag
if (match.index > lastIndex) {
parts.push(children.slice(lastIndex, match.index))
}
// Add the hashtag as a clickable link
const hashtag = match[1]
if (typeof children !== 'string') {
return <>{children}</>
}
// Handle hashtags and wikilinks
const hashtagRegex = /#(\w+)/g
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const allMatches: Array<{index: number, end: number, type: 'hashtag' | 'wikilink', data: any}> = []
let match
while ((match = hashtagRegex.exec(children)) !== null) {
allMatches.push({
index: match.index,
end: match.index + match[0].length,
type: 'hashtag',
data: match[1]
})
}
while ((match = wikilinkRegex.exec(children)) !== null) {
const content = match[1]
let target = content.includes('|') ? content.split('|')[0].trim() : content.trim()
let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim()
if (content.startsWith('book:')) {
target = content.replace('book:', '').trim()
}
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
allMatches.push({
index: match.index,
end: match.index + match[0].length,
type: 'wikilink',
data: { dtag, displayText }
})
}
if (allMatches.length === 0) return <>{children}</>
allMatches.sort((a, b) => a.index - b.index)
const parts: (string | JSX.Element)[] = []
let lastIndex = 0
for (const match of allMatches) {
if (match.index > lastIndex) {
parts.push(children.slice(lastIndex, match.index))
}
if (match.type === 'hashtag') {
parts.push(
<SecondaryPageLink
key={match.index}
to={toNoteList({ hashtag, kinds: [kinds.LongFormArticle] })}
className="text-green-600 dark:text-green-400 hover:underline"
>
#{hashtag}
<SecondaryPageLink key={`h-${match.index}`} to={`/notes?t=${match.data.toLowerCase()}`} className="text-green-600 dark:text-green-400 hover:underline">
#{match.data}
</SecondaryPageLink>
)
lastIndex = match.index + match[0].length
} else {
parts.push(<Wikilink key={`w-${match.index}`} dTag={match.data.dtag} displayText={match.data.displayText} />)
}
// Add remaining text
if (lastIndex < children.length) {
parts.push(children.slice(lastIndex))
}
lastIndex = match.end
}
return <>{parts}</>
if (lastIndex < children.length) {
parts.push(children.slice(lastIndex))
}
return <>{children}</>
return <>{parts}</>
},
img: ({ src }) => {
if (!src) return null
@ -183,7 +344,7 @@ export default function MarkdownArticle({ @@ -183,7 +344,7 @@ export default function MarkdownArticle({
)
}
}) as Components,
[showImageGallery, event.pubkey]
[showImageGallery, event.pubkey, mediaUrls, event.kind]
)
return (
@ -273,19 +434,76 @@ export default function MarkdownArticle({ @@ -273,19 +434,76 @@ export default function MarkdownArticle({
className="w-full max-w-[400px] aspect-[3/1] object-cover my-0"
/>
)}
<Markdown
remarkPlugins={[remarkGfm, remarkMath, remarkNostr]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
<div className="break-words whitespace-pre-wrap">
{event.content.split(/(#\w+|\[\[[^\]]+\]\])/).map((part, index, array) => {
// Check if this part is a hashtag
if (part.match(/^#\w+$/)) {
const hashtag = part.slice(1)
// Add spaces before and after unless at start/end of line
const isStartOfLine = index === 0 || array[index - 1].match(/^[\s]*$/) !== null
const isEndOfLine = index === array.length - 1 || array[index + 1].match(/^[\s]*$/) !== null
const beforeSpace = isStartOfLine ? '' : ' '
const afterSpace = isEndOfLine ? '' : ' '
return (
<span key={`hashtag-wrapper-${index}`}>
{beforeSpace && beforeSpace}
<a
href={`/notes?t=${hashtag.toLowerCase()}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const url = `/notes?t=${hashtag.toLowerCase()}`
console.log('[MarkdownArticle] Clicking hashtag, navigating to:', url)
push(url)
}}
>
{part}
</a>
{afterSpace && afterSpace}
</span>
)
}
return url
}}
components={components}
>
{event.content}
</Markdown>
// Check if this part is a wikilink
if (part.match(/^\[\[([^\]]+)\]\]$/)) {
const content = part.slice(2, -2)
let target = content.includes('|') ? content.split('|')[0].trim() : content.trim()
let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim()
if (content.startsWith('book:')) {
target = content.replace('book:', '').trim()
}
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return <Wikilink key={`wikilink-${index}`} dTag={dtag} displayText={displayText} />
}
// Regular text
return <Markdown key={`text-${index}`} remarkPlugins={[remarkGfm, remarkMath, remarkNostr]} components={components}>{part}</Markdown>
})}
</div>
{/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */}
{!showImageGallery && mediaUrls.length > 0 && (
<div className="space-y-4 mt-4">
{mediaUrls.map((url) => {
const extension = url.split('.').pop()?.toLowerCase()
// Images are already handled by the img component
if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) {
return null
}
// Render audio and video
return (
<MediaPlayer key={url} src={url} mustLoad={true} className="w-full" />
)
})}
</div>
)}
{/* Image Carousel - Only show for article content (30023, 30041, 30818) */}
{showImageGallery && allImages.length > 0 && (

61
src/components/Note/MarkdownArticle/remarkHashtags.ts

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
import type { PhrasingContent, Root, Text } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
const HASHTAG_REGEX = /#([a-zA-Z0-9_]+)/g
export const remarkHashtags: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
const matches = Array.from(text.matchAll(HASHTAG_REGEX))
if (matches.length === 0) return
const children: PhrasingContent[] = []
let lastIndex = 0
matches.forEach((match) => {
const matchStart = match.index!
const matchEnd = matchStart + match[0].length
const hashtag = match[1]
// Add text before the hashtag
if (matchStart > lastIndex) {
children.push({
type: 'text',
value: text.slice(lastIndex, matchStart)
})
}
// Create a link node for the hashtag
children.push({
type: 'link',
url: `/notes?t=${hashtag.toLowerCase()}`,
children: [
{
type: 'text',
value: `#${hashtag}`
}
]
})
lastIndex = matchEnd
})
// Add remaining text after the last match
if (lastIndex < text.length) {
children.push({
type: 'text',
value: text.slice(lastIndex)
})
}
// Replace the text node with the processed children
parent.children.splice(index, 1, ...children)
})
}
}

166
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -7,14 +7,7 @@ import { generateBech32IdFromATag } from '@/lib/tag' @@ -7,14 +7,7 @@ import { generateBech32IdFromATag } from '@/lib/tag'
import client from '@/services/client.service'
import logger from '@/lib/logger'
import { Button } from '@/components/ui/button'
import { contentParserService } from '@/services/content-parser.service'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreVertical, FileDown } from 'lucide-react'
import { MoreVertical } from 'lucide-react'
interface PublicationReference {
coordinate: string
@ -147,8 +140,8 @@ export default function PublicationIndex({ @@ -147,8 +140,8 @@ export default function PublicationIndex({
}
}
// Export publication in different formats
const exportPublication = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => {
// Export publication as AsciiDoc
const exportPublication = async () => {
try {
// Collect all content from references
const contentParts: string[] = []
@ -159,107 +152,15 @@ export default function PublicationIndex({ @@ -159,107 +152,15 @@ export default function PublicationIndex({
// Extract title
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled'
// Extract raw content
let content = ref.event.content
if (format === 'adoc') {
// For AsciiDoc, output the raw content with title
contentParts.push(`= ${title}\n\n${content}\n\n`)
} else if (format === 'html') {
// For HTML, parse the AsciiDoc content to HTML
const parsedContent = await contentParserService.parseContent(content, {
eventKind: ref.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
contentParts.push(`<article>
<h1>${title}</h1>
${parsedContent.html}
</article>`)
} else if (format === 'latex') {
// Convert to LaTeX
content = content.replace(/^= (.+)$/gm, '\\section{$1}')
content = content.replace(/^== (.+)$/gm, '\\subsection{$1}')
content = content.replace(/^=== (.+)$/gm, '\\subsubsection{$1}')
contentParts.push(`\\section*{${title}}\n\n${content}\n\n`)
} else if (format === 'pdf' || format === 'epub') {
// For PDF/EPUB, we need to export as HTML that can be converted
// Parse the AsciiDoc content to HTML using the content parser
const parsedContent = await contentParserService.parseContent(content, {
eventKind: ref.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
contentParts.push(`<article>
<h1>${title}</h1>
${parsedContent.html}
</article>`)
}
// For AsciiDoc, output the raw content with title
contentParts.push(`= ${title}\n\n${ref.event.content}\n\n`)
}
const fullContent = contentParts.join('\n')
const filename = `${metadata.title || 'publication'}.${format}`
let blob: Blob = new Blob([''])
if (format === 'html') {
// For HTML, wrap the content in a full HTML document
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${metadata.title || 'Publication'}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
${fullContent}
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else if (format === 'pdf' || format === 'epub') {
// For PDF/EPUB, wrap the HTML content in a full HTML document
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${metadata.title || 'Publication'}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
${fullContent}
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else {
// For AsciiDoc or LaTeX formats, use the raw content
blob = new Blob([fullContent], {
type: format === 'latex' ? 'text/plain' : 'text/plain'
})
}
const filename = `${metadata.title || 'publication'}.adoc`
// Export as AsciiDoc
const blob = new Blob([fullContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@ -270,7 +171,7 @@ export default function PublicationIndex({ @@ -270,7 +171,7 @@ export default function PublicationIndex({
document.body.removeChild(a)
URL.revokeObjectURL(url)
logger.info(`[PublicationIndex] Exported publication as ${format}`)
logger.info('[PublicationIndex] Exported publication as .adoc')
} catch (error) {
logger.error('[PublicationIndex] Error exporting publication:', error)
alert('Failed to export publication. Please try again.')
@ -315,10 +216,13 @@ export default function PublicationIndex({ @@ -315,10 +216,13 @@ export default function PublicationIndex({
setIsLoading(true)
const fetchedRefs: PublicationReference[] = []
// Capture current visitedIndices at the start of the fetch
const currentVisited = visitedIndices
for (const ref of referencesData) {
// Skip if this is a 30040 event we've already visited (prevent circular references)
if (ref.kind === ExtendedKind.PUBLICATION) {
if (visitedIndices.has(ref.coordinate)) {
if (currentVisited.has(ref.coordinate)) {
logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate)
fetchedRefs.push({ ...ref, event: undefined })
continue
@ -357,7 +261,7 @@ export default function PublicationIndex({ @@ -357,7 +261,7 @@ export default function PublicationIndex({
} else {
setIsLoading(false)
}
}, [referencesData, visitedIndices])
}, [referencesData, visitedIndices]) // Now include visitedIndices but capture it inside
return (
<div className={cn('space-y-6', className)}>
@ -366,35 +270,15 @@ export default function PublicationIndex({ @@ -366,35 +270,15 @@ export default function PublicationIndex({
<header className="mb-8 border-b pb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => exportPublication('html')}>
<FileDown className="mr-2 h-4 w-4" />
Export as HTML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('adoc')}>
<FileDown className="mr-2 h-4 w-4" />
Export as AsciiDoc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('pdf')}>
<FileDown className="mr-2 h-4 w-4" />
Export as PDF
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('epub')}>
<FileDown className="mr-2 h-4 w-4" />
Export as EPUB
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('latex')}>
<FileDown className="mr-2 h-4 w-4" />
Export as LaTeX
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={exportPublication}
title="Export as AsciiDoc"
>
<MoreVertical className="h-5 w-5" />
</Button>
</div>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed">
@ -506,7 +390,7 @@ function ToCItemComponent({ @@ -506,7 +390,7 @@ function ToCItemComponent({
<li className={cn('list-none', indentClass)}>
<button
onClick={() => onItemClick(item.coordinate)}
className="text-left text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline cursor-pointer"
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
>
{item.title}
</button>

Loading…
Cancel
Save