package generator import ( "encoding/json" "fmt" "html/template" "os" "path/filepath" "sort" "strings" "time" "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/generator/atom" "codeberg.org/snonux/snonux/internal/post" ) // pageData holds the template variables for a single HTML page. type pageData struct { Posts []postView PrevPage string // URL of the newer page, empty if none NextPage string // URL of the older page, empty if none PrevPageJSON template.JS NextPageJSON template.JS ThemeSoundsJSON template.JS // Web Audio preset for this theme (splash + nav) } // postView is a render-friendly representation of a post for the HTML template. type postView struct { ID string FormattedTime string ContentHTML template.HTML // pre-rendered; trusted — generated by this tool } // Run loads all posts, generates all HTML pages, and writes atom.xml. func Run(cfg *config.Config) error { posts, err := loadAllPosts(cfg.OutputDir) if err != nil { return err } // Sort newest-first so page 1 (index.html) has the latest content. sort.Slice(posts, func(i, j int) bool { return posts[i].Timestamp.After(posts[j].Timestamp) }) pages := paginate(posts, config.PostsPerPage) // Combine the theme HTML (which uses {{template "navhints"}} etc.) with the // shared navDefs sub-templates so a single parse call resolves all references. combined := injectSharedHead(getTheme(cfg.Theme)) + "\n" + navDefs tmpl, err := template.New("page").Parse(combined) if err != nil { return fmt.Errorf("parse page template: %w", err) } if err := writeFavicon(cfg.OutputDir); err != nil { return err } for i, page := range pages { if err := writePage(tmpl, page, i, len(pages), cfg); err != nil { return err } } return atom.Generate(posts, cfg) } // loadAllPosts walks outdir/posts/ and deserialises every post.json found. func loadAllPosts(outputDir string) ([]*post.Post, error) { postsDir := filepath.Join(outputDir, "posts") entries, err := os.ReadDir(postsDir) if os.IsNotExist(err) { return nil, nil // no posts yet — normal on first run } if err != nil { return nil, fmt.Errorf("read posts dir: %w", err) } var posts []*post.Post for _, entry := range entries { if !entry.IsDir() { continue } p, err := post.Load(filepath.Join(postsDir, entry.Name())) if err != nil { return nil, err } posts = append(posts, p) } return posts, nil } // paginate splits posts into chunks of size pageSize. func paginate(posts []*post.Post, pageSize int) [][]*post.Post { var pages [][]*post.Post for i := 0; i < len(posts); i += pageSize { end := i + pageSize if end > len(posts) { end = len(posts) } pages = append(pages, posts[i:end]) } return pages } // pageFilename returns "index.html" for page 0 and "pageN.html" for page N>0. func pageFilename(index int) string { if index == 0 { return "index.html" } return fmt.Sprintf("page%d.html", index+1) } // indexPageNavURL is the href for pagination links to the first page. splash=0 // is read by splashGate so the splash does not run (referrer is unreliable for // keyboard / programmatic navigation from page2.html → index.html). const indexPageNavURL = "index.html?splash=0" // writePage renders one HTML page and writes it to cfg.OutputDir. func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config) error { data := buildPageData(posts, pageIndex, totalPages, cfg.Theme) filename := pageFilename(pageIndex) path := filepath.Join(cfg.OutputDir, filename) f, err := os.Create(path) if err != nil { return fmt.Errorf("create %s: %w", filename, err) } defer f.Close() if err := tmpl.Execute(f, data); err != nil { return fmt.Errorf("render %s: %w", filename, err) } return nil } // buildPageData constructs the template data for a single page. func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string) pageData { views := make([]postView, len(posts)) for i, p := range posts { views[i] = postView{ ID: p.ID, FormattedTime: formatPostTime(p.Timestamp), ContentHTML: template.HTML(p.Content), //nolint:gosec // content is tool-generated HTML } } var prevPage, nextPage string // "Prev" means newer — page index decreases. if pageIndex > 0 { if pageIndex == 1 { prevPage = indexPageNavURL } else { prevPage = pageFilename(pageIndex - 1) } } // "Next" means older — page index increases. if pageIndex < totalPages-1 { nextPage = pageFilename(pageIndex + 1) } return pageData{ Posts: views, PrevPage: prevPage, NextPage: nextPage, PrevPageJSON: jsonStringOrNull(prevPage), NextPageJSON: jsonStringOrNull(nextPage), ThemeSoundsJSON: themeSoundsJSON(theme), } } // formatPostTime formats a UTC timestamp in the style used on posts: "09.04.26 • 14:30 UTC". func formatPostTime(t time.Time) string { utc := t.UTC() return fmt.Sprintf("%02d.%02d.%02d • %02d:%02d UTC", utc.Day(), int(utc.Month()), utc.Year()%100, utc.Hour(), utc.Minute(), ) } // jsonStringOrNull returns a JS-safe JSON string literal for s, or "null" if empty. // The result is safe to embed directly in a