diff --git a/internal/asciidoc/processor.go b/internal/asciidoc/processor.go index 2a8501f..a3b677b 100644 --- a/internal/asciidoc/processor.go +++ b/internal/asciidoc/processor.go @@ -13,6 +13,12 @@ type Processor struct { linkBaseURL string } +// ProcessResult contains the processed HTML content and extracted table of contents +type ProcessResult struct { + Content string + TableOfContents string +} + // NewProcessor creates a new AsciiDoc processor func NewProcessor(linkBaseURL string) *Processor { return &Processor{ @@ -21,23 +27,34 @@ func NewProcessor(linkBaseURL string) *Processor { } // Process converts AsciiDoc content to HTML with link rewriting -func (p *Processor) Process(asciidocContent string) (string, error) { +// Returns both the content HTML and the extracted table of contents +func (p *Processor) Process(asciidocContent string) (*ProcessResult, error) { // First, rewrite links in the AsciiDoc content processedContent := p.rewriteLinks(asciidocContent) // Convert AsciiDoc to HTML using asciidoctor CLI html, err := p.convertToHTML(processedContent) if err != nil { - return "", fmt.Errorf("failed to convert AsciiDoc to HTML: %w", err) + return nil, fmt.Errorf("failed to convert AsciiDoc to HTML: %w", err) } + // Extract table of contents from HTML + toc, contentWithoutTOC := p.extractTOC(html) + // Sanitize HTML to prevent XSS - sanitized := p.sanitizeHTML(html) + sanitized := p.sanitizeHTML(contentWithoutTOC) // Process links: make external links open in new tab, local links in same tab processed := p.processLinks(sanitized) - return processed, nil + // Also sanitize and process links in TOC + tocSanitized := p.sanitizeHTML(toc) + tocProcessed := p.processLinks(tocSanitized) + + return &ProcessResult{ + Content: processed, + TableOfContents: tocProcessed, + }, nil } // rewriteLinks rewrites wikilinks and nostr: links in AsciiDoc content @@ -189,6 +206,122 @@ func (p *Processor) sanitizeHTML(html string) string { return html } +// extractTOC extracts the table of contents from AsciiDoc HTML output +// Returns the TOC HTML and the content HTML without the TOC +func (p *Processor) extractTOC(html string) (string, string) { + // AsciiDoc with toc: 'left' generates a TOC in a div with id="toc" or class="toc" + // We need to match the entire TOC div including nested content + // Since divs can be nested, we need to count opening/closing tags + + var tocContent string + contentWithoutTOC := html + + // Find the start of the TOC div - try multiple patterns + tocStartPatterns := []*regexp.Regexp{ + // Pattern 1:
+ regexp.MustCompile(`(?i)]*>`), + // Pattern 2:
+ regexp.MustCompile(`(?i)]*>`), + // Pattern 3:
+ regexp.MustCompile(`(?i)]*>`), + // Pattern 4:
or + if strings.HasSuffix(tocFullHTML, "
") { + innerEnd -= 6 + } else if strings.HasSuffix(tocFullHTML, "") { + innerEnd -= 7 + } + tocContent = strings.TrimSpace(tocFullHTML[innerStart:innerEnd]) + + // Remove the toctitle div if present (AsciiDoc adds "Table of Contents" title) + toctitlePattern := regexp.MustCompile(`(?s)]*>.*?
\s*`) + tocContent = toctitlePattern.ReplaceAllString(tocContent, "") + tocContent = strings.TrimSpace(tocContent) + + // Remove the TOC from the content + contentWithoutTOC = html[:tocStartIdx] + html[tocEndIdx:] + } + + return tocContent, contentWithoutTOC +} + // processLinks processes HTML links to add target="_blank" to external links // External links are those that start with http:// or https:// and don't point to the linkBaseURL domain // Local links (including relative links and links to linkBaseURL) open in the same tab diff --git a/internal/generator/html.go b/internal/generator/html.go index a5153d5..5fa19ec 100644 --- a/internal/generator/html.go +++ b/internal/generator/html.go @@ -260,8 +260,13 @@ func (g *HTMLGenerator) fetchProfilesBatch(ctx context.Context, pubkeys []string } // ProcessAsciiDoc processes AsciiDoc content to HTML +// Returns only the content HTML (without TOC) for backward compatibility func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) { - return g.asciidocProc.Process(content) + result, err := g.asciidocProc.Process(content) + if err != nil { + return "", err + } + return result.Content, nil } // ProcessMarkdown processes Markdown content to HTML using marked via Node.js @@ -425,7 +430,7 @@ func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, newestBlog // GenerateWikiPage generates a wiki article page func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { // Process AsciiDoc content - htmlContent, err := g.asciidocProc.Process(wiki.Content) + result, err := g.asciidocProc.Process(wiki.Content) if err != nil { return "", err } @@ -438,19 +443,20 @@ func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []Wiki canonicalURL := g.siteURL + "/wiki/" + wiki.DTag data := PageData{ - Title: wiki.Title, - Description: description, - CanonicalURL: canonicalURL, - OGImage: g.siteURL + g.defaultImage, - OGType: "article", - SiteName: g.siteName, - SiteURL: g.siteURL, - CurrentYear: time.Now().Year(), - WikiPages: wikiPages, - FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page - Content: template.HTML(htmlContent), - Summary: wiki.Summary, - Profiles: make(map[string]*nostr.Profile), // Empty profiles for wiki pages + Title: wiki.Title, + Description: description, + CanonicalURL: canonicalURL, + OGImage: g.siteURL + g.defaultImage, + OGType: "article", + SiteName: g.siteName, + SiteURL: g.siteURL, + CurrentYear: time.Now().Year(), + WikiPages: wikiPages, + FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page + Content: template.HTML(result.Content), + Summary: wiki.Summary, + TableOfContents: template.HTML(result.TableOfContents), + Profiles: make(map[string]*nostr.Profile), // Empty profiles for wiki pages } // Add structured data for article diff --git a/static/css/main.css b/static/css/main.css index 1b4b0aa..f896988 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1607,6 +1607,64 @@ textarea:focus-visible { color: var(--link-hover-color); } +/* AsciiDoc TOC specific styling - ensure proper formatting */ +.table-of-contents .sectlevel1, +.table-of-contents .sectlevel2, +.table-of-contents .sectlevel3 { + margin: 0; + padding: 0; +} + +.table-of-contents .sectlevel1 > li, +.table-of-contents .sectlevel2 > li, +.table-of-contents .sectlevel3 > li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +/* Reset any AsciiDoc default styles that might interfere */ +.table-of-contents * { + box-sizing: border-box; +} + +.table-of-contents ul, +.table-of-contents ol { + list-style: none; + margin: 0; +} + +/* Ensure proper nesting for AsciiDoc TOC structure */ +.table-of-contents ul.sectlevel1 { + padding-left: 0; +} + +.table-of-contents ul.sectlevel2 { + margin-top: 0.25rem; + padding-left: 1.25rem; + border-left: 1px solid var(--border-color); +} + +.table-of-contents ul.sectlevel3 { + margin-top: 0.25rem; + padding-left: 1rem; +} + +/* Reset any AsciiDoc positioning or layout that might cause issues */ +.table-of-contents #toc, +.table-of-contents .toc { + position: static !important; + float: none !important; + width: auto !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Ensure list items don't have unwanted spacing */ +.table-of-contents li { + display: list-item; + list-style: none; +} + .ebooks-page { max-width: 1200px; margin: 0 auto; diff --git a/static/css/responsive.css b/static/css/responsive.css index b204b6e..66fd3d2 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -560,6 +560,29 @@ color: var(--link-hover-color); } + /* AsciiDoc TOC specific styling for responsive */ + .table-of-contents .sectlevel1, + .table-of-contents .sectlevel2, + .table-of-contents .sectlevel3 { + margin: 0; + padding: 0; + } + + .table-of-contents ul.sectlevel1 { + padding-left: 0; + } + + .table-of-contents ul.sectlevel2 { + margin-top: 0.25rem; + padding-left: 1.25rem; + border-left: 1px solid var(--border-color); + } + + .table-of-contents ul.sectlevel3 { + margin-top: 0.25rem; + padding-left: 1rem; + } + /* Feed */ .feed-page { padding: 1rem;