package generator
import (
"context"
"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/generator/templates"
"codeberg.org/snonux/snonux/internal/post"
"codeberg.org/snonux/snonux/internal/version"
)
// pageData holds the template variables for a single HTML page.
// Theme-specific values come from the default theme's meta.json — they are
// what the user sees on first paint. shared.js may swap them out at runtime
// when a non-default theme is saved in localStorage.
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
DefaultTheme string // baked into the shell as SNONUX_DEFAULT_THEME
AllThemesJSON template.JS // [...] of all available theme names, JS literal
DefaultTitle string //
contents for the default theme
DefaultHeaderHTML template.HTML // innerHTML for the default theme
DefaultSplashHTML template.HTML // #splash-overlay innerHTML for the default theme
DefaultPrevText template.HTML // pagination prev anchor text (theme-styled)
DefaultNextText template.HTML // pagination next anchor text
DefaultSoundsJSON template.JS // default theme's Web Audio preset (literal)
}
// 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
}
// themeMeta mirrors the JSON structure written by the migration tool and used
// at runtime by shared.js to swap theme markup in.
type themeMeta struct {
Title string `json:"title"`
HeaderHTML string `json:"header_html"`
SplashInnerHTML string `json:"splash_inner_html"`
PrevPageText string `json:"prev_page_text"`
NextPageText string `json:"next_page_text"`
}
// Run loads all posts, generates all HTML pages, and writes atom.xml plus the
// shared CSS/JS bundles and per-theme asset files.
// The ctx parameter is accepted for cancellation propagation; it is passed
// through to I/O-bound calls where possible.
func Run(ctx context.Context, cfg *config.Config) error {
posts, err := loadAllPosts(cfg.OutputDir)
if err != nil {
return err
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].Timestamp.After(posts[j].Timestamp)
})
pages := paginate(posts, config.PostsPerPage)
// Combine shell.tmpl with nav.tmpl partials so a single parse call resolves
// references to splashGate, navhints, navmodal.
shellSrc, err := templates.Shell()
if err != nil {
return fmt.Errorf("load shell template: %w", err)
}
combined := shellSrc + "\n" + getNavDefs()
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
}
if err := writeSharedAssets(cfg.OutputDir); err != nil {
return err
}
if err := writeAllThemeAssets(cfg.OutputDir); err != nil {
return err
}
defaultTheme := validThemeName(cfg.Theme)
defaultMeta, err := loadThemeMeta(defaultTheme)
if err != nil {
return err
}
all, err := allThemesJSON()
if err != nil {
return err
}
for i, page := range pages {
if err := writePage(tmpl, page, i, len(pages), cfg, defaultTheme, defaultMeta, all); err != nil {
return err
}
}
return atom.Generate(ctx, 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)
}
posts := make([]*post.Post, 0, len(entries))
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 {
pageCount := (len(posts) + pageSize - 1) / pageSize
pages := make([][]*post.Post, 0, pageCount)
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, defaultTheme string, defaultMeta themeMeta, all template.JS) error {
data := buildPageData(posts, pageIndex, totalPages, defaultTheme, defaultMeta, all)
filename := pageFilename(pageIndex)
path := filepath.Join(cfg.OutputDir, filename)
tmpFile, err := os.CreateTemp(cfg.OutputDir, filename+".*.tmp")
if err != nil {
return fmt.Errorf("create temp for %s: %w", filename, err)
}
tmpPath := tmpFile.Name()
ok := false
defer func() {
_ = tmpFile.Close()
if !ok {
_ = os.Remove(tmpPath)
}
}()
if err := tmpl.Execute(tmpFile, data); err != nil {
return fmt.Errorf("render %s: %w", filename, err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("close temp %s: %w", filename, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("rename %s: %w", filename, err)
}
ok = true
return nil
}
// buildPageData constructs the template data for a single page.
func buildPageData(posts []*post.Post, pageIndex, totalPages int, defaultTheme string, meta themeMeta, all template.JS) 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),
DefaultTheme: defaultTheme,
AllThemesJSON: all,
DefaultTitle: meta.Title,
DefaultHeaderHTML: template.HTML(meta.HeaderHTML), //nolint:gosec // source is in-tree theme metadata
DefaultSplashHTML: template.HTML(meta.SplashInnerHTML), //nolint:gosec // same
DefaultPrevText: template.HTML(meta.PrevPageText), //nolint:gosec // same
DefaultNextText: template.HTML(meta.NextPageText), //nolint:gosec // same
DefaultSoundsJSON: themeSoundsJSON(defaultTheme),
}
}
// 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