summaryrefslogtreecommitdiff
path: root/internal/generator/generator.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/generator/generator.go')
-rw-r--r--internal/generator/generator.go188
1 files changed, 188 insertions, 0 deletions
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
new file mode 100644
index 0000000..595bb62
--- /dev/null
+++ b/internal/generator/generator.go
@@ -0,0 +1,188 @@
+// Package generator reads all post directories from outdir/posts/, sorts them by
+// timestamp descending, paginates them into HTML pages, and writes atom.xml.
+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/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
+}
+
+// postView is a render-friendly representation of a post for the HTML template.
+type postView struct {
+ 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 := getTheme(cfg.Theme) + "\n" + navDefs
+ tmpl, err := template.New("page").Parse(combined)
+ if err != nil {
+ return fmt.Errorf("parse page template: %w", err)
+ }
+
+ for i, page := range pages {
+ if err := writePage(tmpl, page, i, len(pages), cfg); err != nil {
+ return err
+ }
+ }
+
+ return generateAtom(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)
+}
+
+// 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)
+
+ 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) pageData {
+ views := make([]postView, len(posts))
+ for i, p := range posts {
+ views[i] = postView{
+ 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 {
+ 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),
+ }
+}
+
+// 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 <script> block as a JS value.
+func jsonStringOrNull(s string) template.JS {
+ if s == "" {
+ return "null"
+ }
+
+ b, _ := json.Marshal(s)
+
+ return template.JS(strings.TrimSpace(string(b))) //nolint:gosec // filename is tool-generated
+}