diff options
Diffstat (limited to 'internal/generator/generator.go')
| -rw-r--r-- | internal/generator/generator.go | 188 |
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 +} |
