Browse Source

more parsing updates

imwald
Silberengel 5 months ago
parent
commit
c320bc9728
  1. 2
      src/components/ImageWithLightbox/index.tsx
  2. 105
      src/components/Note/Article/index.tsx
  3. 31
      src/components/Note/SimpleContent/index.tsx
  4. 11
      src/components/Note/index.tsx
  5. 132
      src/components/UniversalContent/TableOfContents.tsx
  6. 191
      src/index.css
  7. 125
      src/services/content-parser.service.ts

2
src/components/ImageWithLightbox/index.tsx

@ -62,7 +62,7 @@ export default function ImageWithLightbox({
key={0} key={0}
className={className} className={className}
classNames={{ classNames={{
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper), wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
image={image} image={image}

105
src/components/Note/Article/index.tsx

@ -8,9 +8,9 @@ import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser' import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview' import WebPreview from '../../WebPreview'
import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview' import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
import TableOfContents from '../../UniversalContent/TableOfContents'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ExtendedKind } from '@/constants'
export default function Article({ export default function Article({
event, event,
@ -23,6 +23,14 @@ export default function Article({
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false) const [isInfoOpen, setIsInfoOpen] = useState(false)
// Determine if this is an article-type event that should show ToC and Article Info
const isArticleType = useMemo(() => {
return event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT
}, [event.kind])
// Use the comprehensive content parser // Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true, enableMath: true,
@ -93,6 +101,46 @@ export default function Article({
} }
}, [parsedContent]) }, [parsedContent])
// Add ToC return buttons to section headers
useEffect(() => {
if (!contentRef.current || !isArticleType || !parsedContent) return
const addTocReturnButtons = () => {
const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6')
if (!headers) return
headers.forEach((header) => {
// Skip if button already exists
if (header.querySelector('.toc-return-btn')) return
// Create the return button
const returnBtn = document.createElement('span')
returnBtn.className = 'toc-return-btn'
returnBtn.innerHTML = '↑ ToC'
returnBtn.title = 'Return to Table of Contents'
// Add click handler
returnBtn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
// Scroll to the ToC
const tocElement = document.getElementById('toc')
if (tocElement) {
tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
// Add the button to the header
header.appendChild(returnBtn)
})
}
// Add buttons after a short delay to ensure content is rendered
const timeoutId = setTimeout(addTocReturnButtons, 100)
return () => clearTimeout(timeoutId)
}, [parsedContent?.html, isArticleType])
if (isLoading) { if (isLoading) {
return ( return (
@ -130,41 +178,16 @@ export default function Article({
{metadata.image && ( {metadata.image && (
<ImageWithLightbox <ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-[3/1] object-cover my-0" className="w-full max-w-[400px] h-auto object-contain my-0"
/> />
)} )}
{/* Table of Contents */}
<TableOfContents
content={parsedContent.html}
className="mb-6"
/>
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div ref={contentRef} dangerouslySetInnerHTML={{ __html: parsedContent.html }} /> <div ref={contentRef} className={isArticleType ? "asciidoc-content" : "simple-content"} dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* Collapsible Article Info */} {/* Collapsible Article Info - only for article-type events */}
{(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0) && ( {isArticleType && (parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0 || parsedContent.hashtags.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4"> <Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between"> <Button variant="outline" className="w-full justify-between">
@ -239,6 +262,28 @@ export default function Article({
</div> </div>
</div> </div>
)} )}
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Tags:</h4>
<div className="flex gap-2 flex-wrap">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 py-1 bg-background text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
</div>
)}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
)} )}

31
src/components/Note/SimpleContent/index.tsx

@ -0,0 +1,31 @@
import { Event } from 'nostr-tools'
import { useEventFieldParser } from '@/hooks/useContentParser'
export default function SimpleContent({
event,
className
}: {
event: Event
className?: string
}) {
// Use the comprehensive content parser but without ToC
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
enableSyntaxHighlighting: true
})
if (isLoading) {
return <div className={className}>Loading...</div>
}
if (error) {
return <div className={className}>Error loading content</div>
}
return (
<div className={`${parsedContent.cssClasses} ${className || ''}`}>
{/* Render content without ToC and Article Info */}
<div className="simple-content" dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
</div>
)
}

11
src/components/Note/index.tsx

@ -28,6 +28,7 @@ import IValue from './IValue'
import LiveEvent from './LiveEvent' import LiveEvent from './LiveEvent'
import LongFormArticlePreview from './LongFormArticlePreview' import LongFormArticlePreview from './LongFormArticlePreview'
import Article from './Article' import Article from './Article'
import SimpleContent from './SimpleContent'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import MutedNote from './MutedNote' import MutedNote from './MutedNote'
@ -110,14 +111,6 @@ export default function Note({
) : ( ) : (
<WikiCard className="mt-2" event={event} /> <WikiCard className="mt-2" event={event} />
) )
} else if (event.kind === ExtendedKind.WIKI_CHAPTER) {
content = showFull ? (
<Article className="mt-2" event={event} />
) : (
<div className="mt-2 p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">Wiki Chapter (part of publication)</div>
</div>
)
} else if (event.kind === ExtendedKind.PUBLICATION) { } else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? ( content = showFull ? (
<Article className="mt-2" event={event} /> <Article className="mt-2" event={event} />
@ -159,7 +152,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} /> content = <Zap className="mt-2" event={event} />
} else { } else {
content = <EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} /> content = <SimpleContent className="mt-2" event={event} />
} }
return ( return (

132
src/components/UniversalContent/TableOfContents.tsx

@ -1,132 +0,0 @@
/**
* Compact Table of Contents component for articles
*/
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, Hash } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
interface TocItem {
id: string
text: string
level: number
}
interface TableOfContentsProps {
content: string
className?: string
}
export default function TableOfContents({ content, className }: TableOfContentsProps) {
const [isOpen, setIsOpen] = useState(false)
const [tocItems, setTocItems] = useState<TocItem[]>([])
useEffect(() => {
// Parse content for headings
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
const items: TocItem[] = []
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1))
const text = heading.textContent?.trim() || ''
if (text) {
// Use existing ID if available, otherwise generate one
const existingId = heading.id
const id = existingId || `heading-${index}-${text.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
items.push({
id,
text,
level
})
}
})
setTocItems(items)
}, [content])
if (tocItems.length === 0) {
return null
}
const scrollToHeading = (item: TocItem) => {
// Try to find the element in the actual DOM
const element = document.getElementById(item.id)
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
setIsOpen(false)
} else {
// Fallback: try to find by text content
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
for (const heading of headings) {
if (heading.textContent?.trim() === item.text) {
heading.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
setIsOpen(false)
break
}
}
}
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={className}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>Table of Contents ({tocItems.length})</span>
</div>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1">
<div className="bg-muted/30 rounded-md p-2 text-xs">
<div className="space-y-1 max-h-48 overflow-y-auto">
{tocItems.map((item) => (
<button
key={item.id}
onClick={() => scrollToHeading(item)}
className={`block w-full text-left hover:text-foreground transition-colors ${
item.level === 1 ? 'font-medium' : ''
} ${
item.level === 2 ? 'ml-2' : ''
} ${
item.level === 3 ? 'ml-4' : ''
} ${
item.level === 4 ? 'ml-6' : ''
} ${
item.level === 5 ? 'ml-8' : ''
} ${
item.level === 6 ? 'ml-10' : ''
}`}
style={{
fontSize: `${Math.max(0.75, 0.9 - (item.level - 1) * 0.05)}rem`,
lineHeight: '1.2'
}}
>
{item.text}
</button>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)
}

191
src/index.css

@ -140,6 +140,197 @@
} }
} }
/* AsciiDoc Table of Contents Styling */
#toc {
@apply bg-muted/30 rounded-lg p-3 sm:p-4 mb-4 sm:mb-6;
}
#toc h2 {
@apply text-lg font-semibold mb-3 text-foreground border-b border-border pb-2;
}
#toc ul {
@apply space-y-1;
}
#toc li {
@apply list-none;
}
#toc a {
@apply text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 block py-1 px-2 rounded hover:bg-primary/10 hover:border-l-2 hover:border-primary hover:pl-3;
}
#toc .sectlevel1 a {
@apply font-medium text-foreground;
}
#toc .sectlevel2 a {
@apply ml-4;
}
#toc .sectlevel3 a {
@apply ml-8;
}
#toc .sectlevel4 a {
@apply ml-12;
}
#toc .sectlevel5 a {
@apply ml-16;
}
#toc .sectlevel6 a {
@apply ml-20;
}
/* Hide the raw AsciiDoc ToC text when styled ToC is present */
.asciidoc-toc-raw {
display: none;
}
/* AsciiDoc Section Headers Styling */
.asciidoc-content h1,
.asciidoc-content h2,
.asciidoc-content h3,
.asciidoc-content h4,
.asciidoc-content h5,
.asciidoc-content h6 {
@apply scroll-mt-24; /* Add padding when scrolling to headers - increased for better visibility */
scroll-behavior: smooth;
position: relative;
}
/* ToC return button styling */
.asciidoc-content h1 .toc-return-btn,
.asciidoc-content h2 .toc-return-btn,
.asciidoc-content h3 .toc-return-btn,
.asciidoc-content h4 .toc-return-btn,
.asciidoc-content h5 .toc-return-btn,
.asciidoc-content h6 .toc-return-btn {
@apply absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-all duration-200 text-muted-foreground hover:text-foreground cursor-pointer;
font-size: 0.75rem;
line-height: 1;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--background) / 0.9);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
white-space: nowrap;
z-index: 10;
box-shadow: 0 1px 3px hsl(var(--foreground) / 0.1);
}
.asciidoc-content h1:hover .toc-return-btn,
.asciidoc-content h2:hover .toc-return-btn,
.asciidoc-content h3:hover .toc-return-btn,
.asciidoc-content h4:hover .toc-return-btn,
.asciidoc-content h5:hover .toc-return-btn,
.asciidoc-content h6:hover .toc-return-btn {
@apply opacity-100;
transform: translateY(-50%) scale(1.05);
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.3);
}
/* Show button on mobile when header is tapped */
@media (hover: none) {
.asciidoc-content h1:active .toc-return-btn,
.asciidoc-content h2:active .toc-return-btn,
.asciidoc-content h3:active .toc-return-btn,
.asciidoc-content h4:active .toc-return-btn,
.asciidoc-content h5:active .toc-return-btn,
.asciidoc-content h6:active .toc-return-btn {
@apply opacity-100;
}
}
/* AsciiDoc Footnotes Styling */
.asciidoc-content .footnote {
@apply text-xs text-muted-foreground;
margin-bottom: 0.5rem; /* Add some spacing between footnotes */
}
.asciidoc-content .footnote a {
@apply text-primary hover:underline;
}
.asciidoc-content .footnote-backref {
@apply ml-1 text-primary hover:underline;
}
.asciidoc-content .footnoteref {
@apply text-primary hover:underline;
vertical-align: super;
font-size: 0.75em;
text-decoration: none;
font-weight: 500;
}
/* Add scroll padding to footnote anchors and content elements */
.asciidoc-content [id^="footnote-"],
.asciidoc-content [id*="footnote"],
.asciidoc-content [id*="_footnote"],
.asciidoc-content [id*="_ref"],
.asciidoc-content p,
.asciidoc-content li {
scroll-margin-top: 8rem; /* Scroll padding for footnote anchors and content */
}
/* Also add scroll padding to any element that might be a footnote target */
.asciidoc-content *[id] {
scroll-margin-top: 8rem;
}
.asciidoc-content .footnoteref:hover {
@apply underline;
}
.asciidoc-content .footnotes {
@apply mt-8 pt-4 border-t border-border;
scroll-margin-top: 4rem; /* Ensure footnotes section doesn't scroll out of view */
}
.asciidoc-content .footnotes ol {
@apply space-y-3; /* Increased spacing between footnote entries */
}
.asciidoc-content .footnotes li {
@apply text-sm text-muted-foreground;
display: block; /* Ensure each footnote is on its own line */
margin-bottom: 0.75rem; /* Additional spacing for better readability */
}
.asciidoc-content .footnotes li p {
@apply mb-2;
margin-top: 0; /* Remove top margin for cleaner look */
}
.asciidoc-content h1 {
@apply text-xl sm:text-2xl font-bold mt-6 sm:mt-8 mb-3 sm:mb-4 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
.asciidoc-content h2 {
@apply text-lg sm:text-xl font-semibold mt-5 sm:mt-6 mb-2 sm:mb-3 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
.asciidoc-content h3 {
@apply text-base sm:text-lg font-medium mt-4 sm:mt-5 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
.asciidoc-content h4 {
@apply text-sm sm:text-base font-medium mt-3 sm:mt-4 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
.asciidoc-content h5 {
@apply text-xs sm:text-sm font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
.asciidoc-content h6 {
@apply text-xs font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200;
}
@keyframes progressFill { @keyframes progressFill {
0% { 0% {
width: 0%; width: 0%;

125
src/services/content-parser.service.ts

@ -4,9 +4,9 @@
*/ */
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection' import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection'
import { Event, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX } from '@/constants' import { URL_REGEX, ExtendedKind } from '@/constants'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
export interface ParsedContent { export interface ParsedContent {
@ -69,7 +69,13 @@ class ContentParserService {
const cssClasses = getMarkupClasses(markupType) const cssClasses = getMarkupClasses(markupType)
// Extract all content elements // Extract all content elements
const media = this.extractAllMedia(content, event) // For article-type events, don't extract media as it should be rendered inline
const isArticleType = eventKind === kinds.LongFormArticle ||
eventKind === ExtendedKind.WIKI_ARTICLE ||
eventKind === ExtendedKind.PUBLICATION ||
eventKind === ExtendedKind.PUBLICATION_CONTENT
const media = isArticleType ? [] : this.extractAllMedia(content, event)
const links = this.extractLinks(content) const links = this.extractLinks(content)
const hashtags = this.extractHashtags(content) const hashtags = this.extractHashtags(content)
const nostrLinks = this.extractNostrLinks(content) const nostrLinks = this.extractNostrLinks(content)
@ -121,6 +127,9 @@ class ContentParserService {
'showtitle': true, 'showtitle': true,
'sectanchors': true, 'sectanchors': true,
'sectlinks': true, 'sectlinks': true,
'toc': 'left',
'toclevels': 6,
'toc-title': 'Table of Contents',
'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none', 'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none',
'stem': options.enableMath ? 'latexmath' : 'none' 'stem': options.enableMath ? 'latexmath' : 'none'
} }
@ -131,8 +140,11 @@ class ContentParserService {
// Process wikilinks in the HTML output // Process wikilinks in the HTML output
const processedHtml = this.processWikilinksInHtml(htmlString) const processedHtml = this.processWikilinksInHtml(htmlString)
// Clean up any leftover markdown syntax // Clean up any leftover markdown syntax and hide raw ToC text
return this.cleanupMarkdown(processedHtml) const cleanedHtml = this.cleanupMarkdown(processedHtml)
// Hide any raw AsciiDoc ToC text that might appear in the content
return this.hideRawTocText(cleanedHtml)
} catch (error) { } catch (error) {
console.error('AsciiDoc parsing error:', error) console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content) return this.parsePlainText(content)
@ -190,15 +202,63 @@ class ContentParserService {
}) })
asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code
// Convert blockquotes // Convert blockquotes - handle multiline blockquotes properly with separate attribution
asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____') asciidoc = asciidoc.replace(/^(>\s+.+(?:\n>\s+.+)*)/gm, (match) => {
const lines = match.split('\n').map(line => line.replace(/^>\s*/, '')) // Remove '>' and optional space from each line
let quoteBodyLines: string[] = []
let attributionLine: string | undefined
// Find the last line that looks like an attribution (starts with '—' or '--')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (line.startsWith('—') || line.startsWith('--')) {
attributionLine = line
quoteBodyLines = lines.slice(0, i) // Everything before the attribution is the quote body
break
}
}
const quoteContent = quoteBodyLines.filter(l => l.trim() !== '').join('\n').trim()
if (attributionLine) {
// Remove leading '—' or '--' from the attribution line
let cleanedAttribution = attributionLine.replace(/^[—-]+/, '').trim()
let author = ''
let source = ''
// Try to find a link:url[text] pattern (already converted from markdown links)
// Example: "George Bernard Shaw, link:https://www.goodreads.com/work/quotes/376394[Man and Superman]"
const linkMatch = cleanedAttribution.match(/^(.*?),?\s*link:([^[\\]]+)\[([^\\]]+)\]$/)
if (linkMatch) {
author = linkMatch[1].trim()
// Use the AsciiDoc link format directly in the source attribute
source = `link:${linkMatch[2].trim()}[${linkMatch[3].trim()}]`
} else {
// If no link, assume the whole thing is author or author, sourceText
const parts = cleanedAttribution.split(',').map(p => p.trim())
author = parts[0]
if (parts.length > 1) {
source = parts.slice(1).join(', ').trim()
}
}
// AsciiDoc blockquote with attribution: [quote, author, source]
return `[quote, ${author}, ${source}]\n____\n${quoteContent}\n____`
} else {
// If no attribution line is found, render as a regular AsciiDoc blockquote
return `____\n${quoteContent}\n____`
}
})
// Convert lists // Convert lists
asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists
// Convert links // Convert links
asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]') asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, 'link:$2[$1]')
// Convert images // Convert images
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]') asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]')
@ -209,6 +269,26 @@ class ContentParserService {
// Convert horizontal rules // Convert horizontal rules
asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'') asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'')
// Convert footnotes - handle both references and definitions for auto-numbering
const footnoteDefinitions: { [id: string]: string } = {}
let tempAsciidoc = asciidoc
// First, extract all footnote definitions and remove them from the content
// This regex captures [^id]: text including multi-line content
tempAsciidoc = tempAsciidoc.replace(/^\[\^([^\]]+)\]:\s*([\s\S]*?)(?=\n\[\^|\n---|\n##|\n###|\n####|\n#####|\n######|$)/gm, (_, id, text) => {
footnoteDefinitions[id] = text.trim()
return '' // Remove the definition line from the content
})
// Then, replace all footnote references [^id] with AsciiDoc's auto-numbered footnote syntax
// using the extracted definitions.
asciidoc = tempAsciidoc.replace(/\[\^([^\]]+)\]/g, (match, id) => {
if (footnoteDefinitions[id]) {
return `footnote:[${footnoteDefinitions[id]}]`
}
return match // If definition not found, leave as is
})
return asciidoc return asciidoc
} }
@ -701,6 +781,35 @@ class ContentParserService {
return '' return ''
} }
} }
/**
* Hide raw AsciiDoc ToC text that might appear in the content
*/
private hideRawTocText(html: string): string {
// Hide any raw ToC text that might be generated by AsciiDoc
// This includes patterns like "# Table of Contents (5)" and plain text lists
let cleaned = html
// Hide raw ToC headings and content
cleaned = cleaned.replace(
/<h[1-6][^>]*>.*?Table of Contents.*?\(\d+\).*?<\/h[1-6]>/gi,
''
)
// Hide raw ToC lists that might appear as plain text
cleaned = cleaned.replace(
/<p[^>]*>.*?Table of Contents.*?\(\d+\).*?<\/p>/gi,
''
)
// Hide any remaining raw ToC text patterns
cleaned = cleaned.replace(
/<p[^>]*>.*?Assumptions.*?\[n=0\].*?<\/p>/gi,
''
)
return cleaned
}
} }
// Export singleton instance // Export singleton instance

Loading…
Cancel
Save