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.
645 lines
18 KiB
645 lines
18 KiB
package generator |
|
|
|
import ( |
|
"bytes" |
|
"context" |
|
"encoding/json" |
|
"fmt" |
|
"html/template" |
|
"path/filepath" |
|
"sync" |
|
"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{}) (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 |
|
}, |
|
"shortenPubkey": func(pubkey string) string { |
|
return nostr.ShortenPubkey(pubkey) |
|
}, |
|
"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 |
|
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 |
|
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 |
|
} |
|
|
|
// UserBadgeInfo represents user badge data for display |
|
type UserBadgeInfo struct { |
|
Pubkey string |
|
Picture string |
|
DisplayName string |
|
Name string |
|
ShortNpub string |
|
} |
|
|
|
// 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", |
|
"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 parallel |
|
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) |
|
} |
|
|
|
profiles := make(map[string]*nostr.Profile) |
|
var mu sync.Mutex |
|
var wg sync.WaitGroup |
|
|
|
// Create a context with timeout for profile fetching |
|
profileCtx, cancel := context.WithTimeout(ctx, 5*time.Second) |
|
defer cancel() |
|
|
|
for _, pubkey := range pubkeys { |
|
if pubkey == "" { |
|
continue |
|
} |
|
wg.Add(1) |
|
go func(pk string) { |
|
defer wg.Done() |
|
// Convert pubkey to npub for fetching |
|
npub, err := nostr.PubkeyToNpub(pk) |
|
if err != nil { |
|
return |
|
} |
|
profile, err := g.nostrClient.FetchProfile(profileCtx, npub) |
|
if err == nil && profile != nil { |
|
mu.Lock() |
|
profiles[pk] = profile |
|
mu.Unlock() |
|
} |
|
}(pubkey) |
|
} |
|
|
|
wg.Wait() |
|
return profiles |
|
} |
|
|
|
// 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) { |
|
// 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 feed authors |
|
ctx := context.Background() |
|
profiles := g.fetchProfilesBatch(ctx, pubkeys) |
|
|
|
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, |
|
Profiles: profiles, |
|
} |
|
|
|
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, |
|
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) |
|
|
|
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(""), |
|
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 |
|
} |
|
|
|
// 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)) |
|
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) |
|
|
|
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 |
|
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 |
|
} |
|
|
|
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 |
|
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 |
|
if repoAnnouncement != nil { |
|
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, |
|
} |
|
} |
|
|
|
// 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(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 + `" |
|
} |
|
}` |
|
}
|
|
|