package generator import ( "bytes" "context" "encoding/json" "fmt" "html/template" "os" "path/filepath" "strings" "time" "gitcitadel-online/internal/asciidoc" "gitcitadel-online/internal/nostr" ) // getTemplateFuncs returns the common template functions func getTemplateFuncs() template.FuncMap { return template.FuncMap{ "year": func() int { return time.Now().Year() }, "json": func(v interface{}) (template.HTML, error) { b, err := json.Marshal(v) if err != nil { return "", err } // Return as template.HTML to prevent HTML escaping return template.HTML(b), nil }, "hasPrefix": func(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }, "shortenPubkey": func(pubkey string) string { return nostr.ShortenPubkey(pubkey) }, "pubkeyToNpub": func(pubkey string) string { npub, err := nostr.PubkeyToNpub(pubkey) if err != nil { // Fallback to hex if conversion fails return pubkey } return npub }, "truncate": func(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." }, "icon": func(name string) template.HTML { // Read icon file from static/icons directory iconPath := filepath.Join("static", "icons", name+".svg") data, err := os.ReadFile(iconPath) if err != nil { // Return empty if icon not found return template.HTML("") } // Remove XML declaration if present svg := strings.TrimSpace(string(data)) if strings.HasPrefix(svg, " tag svgStart := strings.Index(svg, " 0 { svg = svg[svgStart:] } } return template.HTML(svg) }, "dict": func(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict requires an even number of arguments") } dict := make(map[string]interface{}) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } dict[key] = values[i+1] } return dict, nil }, } } // HTMLGenerator generates HTML pages from wiki events type HTMLGenerator struct { templates *template.Template templateDir string asciidocProc *asciidoc.Processor linkBaseURL string siteName string siteURL string defaultImage string nostrClient *nostr.Client } // PageData represents data for a wiki page type PageData struct { Title string Description string Keywords string CanonicalURL string OGImage string OGType string StructuredData template.JS SiteName string SiteURL string CurrentYear int WikiPages []WikiPageInfo BlogItems []BlogItemInfo BlogSummary string ArticleItems []ArticleItemInfo FeedItems []FeedItemInfo EBooks []EBookInfo Content template.HTML Summary string TableOfContents template.HTML Profiles map[string]*nostr.Profile // Map of pubkey -> profile } // WikiPageInfo represents info about a wiki page for navigations type WikiPageInfo struct { DTag string Title string } // BlogItemInfo represents info about a blog item type BlogItemInfo struct { DTag string Title string Summary string Content template.HTML Author string Image string CreatedAt int64 Time string // Formatted time TimeISO string // ISO time } // ArticleItemInfo represents info about a longform article item type ArticleItemInfo struct { DTag string Title string Summary string Content template.HTML Author string Image string CreatedAt int64 Time string // Formatted time TimeISO string // ISO time } // FeedItemInfo represents info about a feed item type FeedItemInfo struct { EventID string Author string Content string Time string TimeISO string Link string } // EBookInfo represents info about an e-book (top-level index event) type EBookInfo struct { EventID string Title string DTag string Author string Summary string Image string Type string CreatedAt int64 Naddr string Time string // Formatted time TimeISO string // ISO time } // EventCardInfo represents info about an event card for the events page type EventCardInfo struct { EventID string Title string DTag string Author string Summary string Image string Kind int URL string // URL to the event page (based on kind:pubkey:dtag) CreatedAt int64 Time string // Formatted time TimeISO string // ISO time } // NewHTMLGenerator creates a new HTML generator func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaultImage string, nostrClient *nostr.Client) (*HTMLGenerator, error) { tmpl := template.New("base").Funcs(getTemplateFuncs()) // Load all templates templateFiles := []string{ "components.html", // Reusable components (feed, alerts, etc.) "base.html", "landing.html", "page.html", "blog.html", "wiki.html", "contact.html", "feed.html", "events.html", "404.html", "500.html", } for _, file := range templateFiles { path := filepath.Join(templateDir, file) _, err := tmpl.ParseFiles(path) if err != nil { return nil, err } } return &HTMLGenerator{ templates: tmpl, templateDir: templateDir, asciidocProc: asciidoc.NewProcessor(linkBaseURL), linkBaseURL: linkBaseURL, siteName: siteName, siteURL: siteURL, defaultImage: defaultImage, nostrClient: nostrClient, }, nil } // fetchProfilesBatch fetches profiles for multiple pubkeys in a single batched query func (g *HTMLGenerator) fetchProfilesBatch(ctx context.Context, pubkeys []string) map[string]*nostr.Profile { if g.nostrClient == nil || len(pubkeys) == 0 { return make(map[string]*nostr.Profile) } // Deduplicate pubkeys pubkeySet := make(map[string]bool) uniquePubkeys := make([]string, 0, len(pubkeys)) for _, pk := range pubkeys { if pk != "" && !pubkeySet[pk] { pubkeySet[pk] = true uniquePubkeys = append(uniquePubkeys, pk) } } if len(uniquePubkeys) == 0 { return make(map[string]*nostr.Profile) } // Create a context with timeout for profile fetching profileCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // Use batch fetch - single query for all profiles profiles, err := g.nostrClient.FetchProfilesBatch(profileCtx, uniquePubkeys) if err != nil { // Log error but return empty map - profiles are optional return make(map[string]*nostr.Profile) } return profiles } // ProcessAsciiDoc processes AsciiDoc content to HTML // Returns only the content HTML (without TOC) for backward compatibility func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) { result, err := g.asciidocProc.Process(content) if err != nil { return "", err } return result.Content, nil } // GenerateLandingPage generates the static landing page func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, newestBlogItem *BlogItemInfo, newestArticleItem *ArticleItemInfo, allArticleItems []ArticleItemInfo, allEBooks []EBookInfo) (string, error) { // Collect pubkeys from blog and article items pubkeys := make([]string, 0) // Add blog and article author pubkeys if available if newestBlogItem != nil && newestBlogItem.Author != "" { pubkeys = append(pubkeys, newestBlogItem.Author) } if newestArticleItem != nil && newestArticleItem.Author != "" { pubkeys = append(pubkeys, newestArticleItem.Author) } // Add all article author pubkeys for _, item := range allArticleItems { if item.Author != "" { pubkeys = append(pubkeys, item.Author) } } // Add all e-book author pubkeys for _, ebook := range allEBooks { if ebook.Author != "" { pubkeys = append(pubkeys, ebook.Author) } } // Fetch profiles for all authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) // Format times for article items and e-books formattedArticleItems := make([]ArticleItemInfo, len(allArticleItems)) for i, item := range allArticleItems { formattedArticleItems[i] = item if item.CreatedAt > 0 { t := time.Unix(item.CreatedAt, 0) formattedArticleItems[i].Time = t.Format("2006-01-02 15:04:05") formattedArticleItems[i].TimeISO = t.Format(time.RFC3339) } } formattedEBooks := make([]EBookInfo, len(allEBooks)) for i, ebook := range allEBooks { formattedEBooks[i] = ebook if ebook.CreatedAt > 0 { t := time.Unix(ebook.CreatedAt, 0) formattedEBooks[i].Time = t.Format("2006-01-02 15:04:05") formattedEBooks[i].TimeISO = t.Format(time.RFC3339) } } // Use default OpenGraph image for all pages ogImage := g.siteURL + g.defaultImage description := "Welcome to " + g.siteName if newestBlogItem != nil || newestArticleItem != nil { description = "Latest content from " + g.siteName } data := PageData{ Title: "Home", Description: description, CanonicalURL: g.siteURL + "/", OGImage: ogImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: wikiPages, FeedItems: []FeedItemInfo{}, // Empty - feed only on feed page Profiles: profiles, } // Add newest blog and article items, plus all articles and e-books to template data type LandingPageData struct { PageData NewestBlogItem *BlogItemInfo NewestArticleItem *ArticleItemInfo AllArticleItems []ArticleItemInfo AllEBooks []EBookInfo } landingData := LandingPageData{ PageData: data, NewestBlogItem: newestBlogItem, NewestArticleItem: newestArticleItem, AllArticleItems: formattedArticleItems, AllEBooks: formattedEBooks, } // Use renderTemplate but with custom data - need to include base.html for DOCTYPE renderTmpl := template.New("landing-render").Funcs(getTemplateFuncs()) files := []string{ filepath.Join(g.templateDir, "components.html"), filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "landing.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse landing templates: %w", err) } var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", landingData); err != nil { return "", fmt.Errorf("failed to render landing template: %w", err) } return buf.String(), nil } // GenerateWikiPage generates a wiki article page func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { // Process AsciiDoc content result, err := g.asciidocProc.Process(wiki.Content) if err != nil { return "", err } description := wiki.Summary if description == "" { description = wiki.Title + " - Wiki article from " + g.siteName } else { description = description + " - " + g.siteName + " Wiki" } canonicalURL := g.siteURL + "/wiki/" + wiki.DTag // Use default OpenGraph image for all pages ogImage := g.siteURL + g.defaultImage data := PageData{ Title: wiki.Title, Description: description, CanonicalURL: canonicalURL, OGImage: ogImage, 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 data.StructuredData = template.JS(g.generateArticleStructuredData(wiki, canonicalURL)) return g.renderTemplate("page.html", data) } // GenerateBlogPage generates the blog index page func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems []BlogItemInfo, feedItems []FeedItemInfo) (string, error) { description := blogIndex.Summary if description == "" { description = "Blog articles from " + g.siteName } canonicalURL := g.siteURL + "/blog" // Format times for blog items and collect pubkeys formattedBlogItems := make([]BlogItemInfo, len(blogItems)) pubkeys := make([]string, 0, len(blogItems)+1) if blogIndex.Author != "" { pubkeys = append(pubkeys, blogIndex.Author) } for i, item := range blogItems { formattedBlogItems[i] = item if item.CreatedAt > 0 { createdTime := time.Unix(item.CreatedAt, 0) formattedBlogItems[i].Time = createdTime.Format("Jan 2, 2006") formattedBlogItems[i].TimeISO = createdTime.Format(time.RFC3339) } if item.Author != "" { pubkeys = append(pubkeys, item.Author) } } // Fetch profiles for blog authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) // Use default OpenGraph image for all pages ogImage := g.siteURL + g.defaultImage // Use blog index title if available, otherwise default to "Blog" title := blogIndex.Title if title == "" { title = "Blog" } data := PageData{ Title: title, Description: description, CanonicalURL: canonicalURL, OGImage: ogImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), BlogItems: formattedBlogItems, BlogSummary: blogIndex.Summary, FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page Content: template.HTML(""), Profiles: profiles, } // Add blog index metadata to template data using a map type BlogPageData struct { PageData BlogIndexTitle string BlogIndexAuthor string BlogIndexImage string BlogIndexSummary string } blogData := BlogPageData{ PageData: data, BlogIndexTitle: blogIndex.Title, BlogIndexAuthor: blogIndex.Author, BlogIndexImage: blogIndex.Image, BlogIndexSummary: blogIndex.Summary, } // Use renderTemplate but with custom data renderTmpl := template.New("blog-render").Funcs(getTemplateFuncs()) files := []string{ filepath.Join(g.templateDir, "components.html"), filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "blog.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse blog templates: %w", err) } var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", blogData); err != nil { return "", fmt.Errorf("failed to execute blog template: %w", err) } return buf.String(), nil } // GenerateArticlesPage generates the articles page with longform articles func (g *HTMLGenerator) GenerateArticlesPage(articleItems []ArticleItemInfo, feedItems []FeedItemInfo) (string, error) { description := "Longform articles from " + g.siteName canonicalURL := g.siteURL + "/articles" // Format times for article items and collect pubkeys formattedArticleItems := make([]ArticleItemInfo, len(articleItems)) pubkeys := make([]string, 0, len(articleItems)) for i, item := range articleItems { formattedArticleItems[i] = item if item.CreatedAt > 0 { createdTime := time.Unix(item.CreatedAt, 0) formattedArticleItems[i].Time = createdTime.Format("Jan 2, 2006") formattedArticleItems[i].TimeISO = createdTime.Format(time.RFC3339) } if item.Author != "" { pubkeys = append(pubkeys, item.Author) } } // Fetch profiles for article authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) // Use default OpenGraph image for all pages ogImage := g.siteURL + g.defaultImage data := PageData{ Title: "Articles", Description: description, CanonicalURL: canonicalURL, OGImage: ogImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), ArticleItems: formattedArticleItems, FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page Content: template.HTML(""), Profiles: profiles, } // Use renderTemplate but with custom data renderTmpl := template.New("articles-render").Funcs(getTemplateFuncs()) files := []string{ filepath.Join(g.templateDir, "components.html"), filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "articles.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse articles templates: %w", err) } var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil { return "", fmt.Errorf("failed to execute articles template: %w", err) } return buf.String(), nil } // GenerateWikiIndexPage generates the wiki index page func (g *HTMLGenerator) GenerateWikiIndexPage(wikiIndex *nostr.IndexEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { description := "Wiki documentation from " + g.siteName summary := "" if wikiIndex != nil { if wikiIndex.Summary != "" { description = wikiIndex.Summary summary = wikiIndex.Summary } } canonicalURL := g.siteURL + "/wiki" // Enhanced description for wiki index if description == "" { description = "Browse wiki documentation and articles from " + g.siteName } data := PageData{ Title: "Wiki", Description: description, CanonicalURL: canonicalURL, OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: wikiPages, FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page Summary: summary, } return g.renderTemplate("wiki.html", data) } // GenerateEBooksPage generates the e-books listing page func (g *HTMLGenerator) GenerateEBooksPage(ebooks []EBookInfo, feedItems []FeedItemInfo) (string, error) { canonicalURL := g.siteURL + "/ebooks" // Format times for display formattedEBooks := make([]EBookInfo, len(ebooks)) pubkeys := make([]string, 0, len(ebooks)) for i, ebook := range ebooks { formattedEBooks[i] = ebook createdTime := time.Unix(ebook.CreatedAt, 0) formattedEBooks[i].Time = createdTime.Format("2006-01-02 15:04:05") formattedEBooks[i].TimeISO = createdTime.Format(time.RFC3339) if ebook.Author != "" { pubkeys = append(pubkeys, ebook.Author) } } // Fetch profiles for all authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) // Use default OpenGraph image for all pages ogImage := g.siteURL + g.defaultImage description := "Browse top-level publications (index events) from Nostr" if len(formattedEBooks) > 0 { description = fmt.Sprintf("Browse %d e-books and publications from Nostr", len(formattedEBooks)) } data := PageData{ Title: "E-Books", Description: description, CanonicalURL: canonicalURL, OGImage: ogImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: []WikiPageInfo{}, FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page EBooks: formattedEBooks, Content: template.HTML(""), // Content comes from template Profiles: profiles, } return g.renderTemplate("ebooks.html", data) } // GenerateContactPage generates the contact form page func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string, repoAnnouncement *nostr.RepoAnnouncement, feedItems []FeedItemInfo, profile *nostr.Profile) (string, error) { // Prepare form data with defaults subject := "" content := "" labels := "" if formData != nil { subject = formData["subject"] content = formData["content"] labels = formData["labels"] } // Create form data struct for template type ContactFormData struct { Subject string Content string Labels string } type ContactPageData struct { PageData Success bool Error string EventID string FormData ContactFormData } description := "Get in touch with " + g.siteName if repoAnnouncement != nil && repoAnnouncement.DTag != "" { description = "Contact " + g.siteName + " - Submit issues, feedback, or questions" } data := ContactPageData{ PageData: PageData{ Title: "Contact", Description: description, CanonicalURL: g.siteURL + "/contact", OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: []WikiPageInfo{}, // Will be populated if needed BlogItems: []BlogItemInfo{}, // Empty - no blog items on contact page FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page Profiles: make(map[string]*nostr.Profile), // Empty profiles for contact page }, Success: success, Error: errorMsg, EventID: eventID, FormData: ContactFormData{ Subject: subject, Content: content, Labels: labels, }, } // Parse base.html together with contact.html to ensure correct blocks are used renderTmpl := template.New("render").Funcs(getTemplateFuncs()) // Parse base.html, components.html, and contact.html together files := []string{ filepath.Join(g.templateDir, "components.html"), // Reusable components filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "contact.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse templates: %w", err) } // Create a map that includes both PageData fields and contact-specific fields templateData := map[string]interface{}{ "Title": data.Title, "Description": data.Description, "CanonicalURL": data.CanonicalURL, "OGImage": data.OGImage, "OGType": data.OGType, "SiteName": data.SiteName, "SiteURL": data.SiteURL, "CurrentYear": data.CurrentYear, "WikiPages": data.WikiPages, "BlogItems": data.BlogItems, "FeedItems": []FeedItemInfo{}, // Empty - feed only on landing page "Success": data.Success, "Error": data.Error, "EventID": data.EventID, "FormData": data.FormData, "Profiles": make(map[string]*nostr.Profile), // Empty profiles for contact page } // Add repo announcement data for JavaScript // Only include if Pubkey and DTag are both set (required fields) if repoAnnouncement != nil && repoAnnouncement.Pubkey != "" && repoAnnouncement.DTag != "" { templateData["RepoAnnouncement"] = map[string]interface{}{ "Pubkey": repoAnnouncement.Pubkey, "DTag": repoAnnouncement.DTag, "Relays": repoAnnouncement.Relays, "Maintainers": repoAnnouncement.Maintainers, } } // Add profile data if profile != nil { templateData["Profile"] = map[string]interface{}{ "Pubkey": profile.Pubkey, "Name": profile.Name, "DisplayName": profile.DisplayName, "About": profile.About, "Picture": profile.Picture, "Website": profile.Website, "NIP05": profile.NIP05, "Lud16": profile.Lud16, "Banner": profile.Banner, } } // Add contact relays for JavaScript templateData["ContactRelays"] = g.nostrClient.GetContactRelays() // Execute base.html which will use the blocks from contact.html var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil { return "", fmt.Errorf("failed to execute base template: %w", err) } return buf.String(), nil } // GenerateErrorPage generates an error page (404 or 500) using base.html func (g *HTMLGenerator) GenerateErrorPage(statusCode int, feedItems []FeedItemInfo) (string, error) { var title, message string switch statusCode { case 404: title = "404 - Page Not Found" message = "The page you're looking for doesn't exist." case 500: title = "500 - Server Error" message = "Something went wrong on our end. Please try again later." default: title = "Error" message = "An error occurred." } canonicalURL := g.siteURL switch statusCode { case 404: canonicalURL = g.siteURL + "/404" case 500: canonicalURL = g.siteURL + "/500" } data := PageData{ Title: title, Description: message, CanonicalURL: canonicalURL, OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: []WikiPageInfo{}, FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page Content: template.HTML(""), // Content will come from the template } return g.renderTemplate(fmt.Sprintf("%d.html", statusCode), data) } // GenerateEventsPage generates the events page for a specific d-tag func (g *HTMLGenerator) GenerateEventsPage(dTag string, eventCards []EventCardInfo, feedItems []FeedItemInfo) (string, error) { canonicalURL := g.siteURL + "/events?d=" + dTag // Collect pubkeys from event cards pubkeys := make([]string, 0, len(eventCards)) seenPubkeys := make(map[string]bool) for _, card := range eventCards { if card.Author != "" && !seenPubkeys[card.Author] { pubkeys = append(pubkeys, card.Author) seenPubkeys[card.Author] = true } } // Fetch profiles for all authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) description := fmt.Sprintf("Events with d-tag: %s", dTag) if len(eventCards) > 0 { description = fmt.Sprintf("Browse %d events with d-tag: %s", len(eventCards), dTag) } data := PageData{ Title: fmt.Sprintf("Events: %s", dTag), Description: description, CanonicalURL: canonicalURL, OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: []WikiPageInfo{}, FeedItems: feedItems, Content: template.HTML(""), // Content comes from template Profiles: profiles, } // Add event cards to a custom field - we'll need to extend PageData or use a map // For now, let's use a custom template data structure type EventsPageData struct { PageData EventCards []EventCardInfo DTag string } eventsData := EventsPageData{ PageData: data, EventCards: eventCards, DTag: dTag, } // Use renderTemplate but with custom data for events renderTmpl := template.New("events-render").Funcs(getTemplateFuncs()) files := []string{ filepath.Join(g.templateDir, "components.html"), filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "events.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse events templates: %w", err) } var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", eventsData); err != nil { return "", fmt.Errorf("failed to execute events template: %w", err) } return buf.String(), nil } // GenerateFeedPage generates a full feed page func (g *HTMLGenerator) GenerateFeedPage(feedItems []FeedItemInfo) (string, error) { canonicalURL := g.siteURL + "/feed" // Collect pubkeys from feed items pubkeys := make([]string, 0, len(feedItems)) for _, item := range feedItems { if item.Author != "" { pubkeys = append(pubkeys, item.Author) } } // Fetch profiles for all authors ctx := context.Background() profiles := g.fetchProfilesBatch(ctx, pubkeys) description := "Recent notes from The Forest relay" if len(feedItems) > 0 { description = fmt.Sprintf("Browse %d recent notes from The Forest relay", len(feedItems)) } data := PageData{ Title: "The Forest Feed", Description: description, CanonicalURL: canonicalURL, OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: []WikiPageInfo{}, FeedItems: feedItems, Content: template.HTML(""), // Content comes from template Profiles: profiles, } // Use renderTemplate but with custom data for feed renderTmpl := template.New("feed-render").Funcs(getTemplateFuncs()) files := []string{ filepath.Join(g.templateDir, "components.html"), filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, "feed.html"), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse feed templates: %w", err) } var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil { return "", fmt.Errorf("failed to execute feed template: %w", err) } return buf.String(), nil } // renderTemplate renders a template with the base template func (g *HTMLGenerator) renderTemplate(templateName string, data PageData) (string, error) { // Parse base.html together with the specific template to ensure correct blocks are used // This avoids the issue where all templates are parsed together and the last "content" block wins renderTmpl := template.New("render").Funcs(getTemplateFuncs()) // Parse base.html, components.html, and the specific template together // This ensures the correct "content" block and reusable components are available files := []string{ filepath.Join(g.templateDir, "components.html"), // Reusable components filepath.Join(g.templateDir, "base.html"), filepath.Join(g.templateDir, templateName), } _, err := renderTmpl.ParseFiles(files...) if err != nil { return "", fmt.Errorf("failed to parse templates: %w", err) } // Execute base.html which will use the blocks from templateName var buf bytes.Buffer if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil { return "", fmt.Errorf("failed to execute base template: %w", err) } return buf.String(), nil } // generateArticleStructuredData generates JSON-LD structured data for an article func (g *HTMLGenerator) generateArticleStructuredData(wiki *nostr.WikiEvent, url string) string { // This is a simplified version - in production, use proper JSON encoding return `{ "@context": "https://schema.org", "@type": "Article", "headline": "` + wiki.Title + `", "description": "` + wiki.Summary + `", "url": "` + url + `", "publisher": { "@type": "Organization", "name": "` + g.siteName + `" } }` }