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.
 
 
 
 
 

939 lines
27 KiB

package generator
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"os"
"os/exec"
"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{}) (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)
},
"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, "<?xml") {
// Find the <svg> tag
svgStart := strings.Index(svg, "<svg")
if svgStart > 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
}
// 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",
"feed.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
func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) {
return g.asciidocProc.Process(content)
}
// ProcessMarkdown processes Markdown content to HTML using marked via Node.js
func (g *HTMLGenerator) ProcessMarkdown(markdownContent string) (string, error) {
// Check if node is available
cmd := exec.Command("node", "--version")
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("node.js not found: %w", err)
}
// JavaScript code to run marked
jsCode := `
const { marked } = require('marked');
let content = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
content += chunk;
});
process.stdin.on('end', () => {
try {
// Configure marked options
marked.setOptions({
breaks: true,
gfm: true,
headerIds: true,
mangle: false
});
const html = marked.parse(content);
process.stdout.write(html);
} catch (error) {
console.error('Error converting Markdown:', error.message);
process.exit(1);
}
});
`
// Run node with the JavaScript code, passing content via stdin
cmd = exec.Command("node", "-e", jsCode)
cmd.Stdin = strings.NewReader(markdownContent)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("marked conversion failed: %w, stderr: %s", err, stderr.String())
}
return stdout.String(), 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)
}
}
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: []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
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
}
// 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)
data := PageData{
Title: "Articles",
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
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"
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,
}
}
// 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)
}
// 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)
data := PageData{
Title: "TheForest Feed",
Description: "Recent notes from TheForest relay",
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 + `"
}
}`
}