You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

553 lines
16 KiB

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 + `"
}
}`
}