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;