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

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