package generator import ( "bytes" "encoding/json" "fmt" "html/template" "path/filepath" "time" "gitcitadel-online/internal/asciidoc" "gitcitadel-online/internal/nostr" ) // 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 } // 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 FeedItems []FeedItemInfo EBooks []EBookInfo Content template.HTML Summary string TableOfContents template.HTML } // 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 CreatedAt int64 Time string // Formatted time TimeISO string // ISO time } // FeedItemInfo represents info about a feed item type FeedItemInfo struct { 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 Type string CreatedAt int64 Naddr string Time string // Formatted time TimeISO string // ISO time } // NewHTMLGenerator creates a new HTML generator func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaultImage string) (*HTMLGenerator, error) { tmpl := template.New("base").Funcs(template.FuncMap{ "year": func() int { return time.Now().Year() }, "json": func(v interface{}) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil }, "hasPrefix": func(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }, }) // Load all templates templateFiles := []string{ "components.html", // Reusable components (feed, alerts, etc.) "base.html", "landing.html", "page.html", "blog.html", "wiki.html", "contact.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, }, nil } // ProcessAsciiDoc processes AsciiDoc content to HTML func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) { return g.asciidocProc.Process(content) } // GenerateLandingPage generates the static landing page func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { data := PageData{ Title: "Home", Description: "Welcome to " + g.siteName, CanonicalURL: g.siteURL + "/", OGImage: g.siteURL + g.defaultImage, OGType: "website", SiteName: g.siteName, SiteURL: g.siteURL, CurrentYear: time.Now().Year(), WikiPages: wikiPages, FeedItems: feedItems, } return g.renderTemplate("landing.html", data) } // 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) if err != nil { return "", err } description := wiki.Summary if description == "" { description = wiki.Title } 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, } // 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 formattedBlogItems := make([]BlogItemInfo, len(blogItems)) 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) } } data := PageData{ Title: "Blog", Description: description, CanonicalURL: canonicalURL, OGImage: g.siteURL + g.defaultImage, 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(""), } // 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(template.FuncMap{ "year": func() int { return time.Now().Year() }, "json": func(v interface{}) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil }, "hasPrefix": func(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }, }) 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 } // 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" 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)) 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) } data := PageData{ Title: "E-Books", Description: "Browse top-level publications (index events) from Nostr", 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 EBooks: formattedEBooks, Content: template.HTML(""), // Content comes from template } 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) (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 } data := ContactPageData{ PageData: PageData{ Title: "Contact", Description: "Contact " + g.siteName, 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 FeedItems: []FeedItemInfo{}, // Empty - feed only on landing 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(template.FuncMap{ "year": func() int { return time.Now().Year() }, "json": func(v interface{}) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil }, "hasPrefix": func(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }, }) // 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, } // Add repo announcement data for JavaScript if repoAnnouncement != nil { templateData["RepoAnnouncement"] = map[string]interface{}{ "Pubkey": repoAnnouncement.Pubkey, "DTag": repoAnnouncement.DTag, "Relays": repoAnnouncement.Relays, "Maintainers": repoAnnouncement.Maintainers, } } // 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) } // 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(template.FuncMap{ "year": func() int { return time.Now().Year() }, "json": func(v interface{}) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil }, "hasPrefix": func(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }, }) // 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 + `" } }` }