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 // <header> 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 <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 } // writeSharedAssets dumps shared.css and shared.js to the output dir. They are // linked from every page and cached by browsers across navigations. func writeSharedAssets(outputDir string) error { css, err := templates.SharedCSS() if err != nil { return fmt.Errorf("read shared.css: %w", err) } if err := os.WriteFile(filepath.Join(outputDir, "shared.css"), css, 0o644); err != nil { return fmt.Errorf("write shared.css: %w", err) } js, err := templates.SharedJS() if err != nil { return fmt.Errorf("read shared.js: %w", err) } if err := os.WriteFile(filepath.Join(outputDir, "shared.js"), js, 0o644); err != nil { return fmt.Errorf("write shared.js: %w", err) } return nil } // writeAllThemeAssets copies each theme's CSS, JS, meta.json, and writes its // sounds.json to dist/themes/<name>/. shared.js fetches these on theme switch. func writeAllThemeAssets(outputDir string) error { names, err := templates.ThemeNames() if err != nil { return err } root := filepath.Join(outputDir, "themes") if err := os.MkdirAll(root, 0o755); err != nil { return fmt.Errorf("create themes dir: %w", err) } for _, name := range names { dir := filepath.Join(root, name) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create theme dir %q: %w", name, err) } if err := writeThemeAsset(dir, name); err != nil { return err } } return nil } // writeThemeAsset copies the four per-theme files into dir. func writeThemeAsset(dir, name string) error { css, err := templates.ThemeCSS(name) if err != nil { return fmt.Errorf("read %s/theme.css: %w", name, err) } if err := os.WriteFile(filepath.Join(dir, "theme.css"), css, 0o644); err != nil { return fmt.Errorf("write %s/theme.css: %w", name, err) } js, err := templates.ThemeJS(name) if err != nil { return fmt.Errorf("read %s/theme.js: %w", name, err) } if err := os.WriteFile(filepath.Join(dir, "theme.js"), js, 0o644); err != nil { return fmt.Errorf("write %s/theme.js: %w", name, err) } meta, err := themeMetaJSON(name) if err != nil { return fmt.Errorf("read %s/meta.json: %w", name, err) } if err := os.WriteFile(filepath.Join(dir, "meta.json"), meta, 0o644); err != nil { return fmt.Errorf("write %s/meta.json: %w", name, err) } soundsJSON := themeSoundsJSON(name) if err := os.WriteFile(filepath.Join(dir, "sounds.json"), []byte(soundsJSON), 0o644); err != nil { return fmt.Errorf("write %s/sounds.json: %w", name, err) } // Copy any additional per-theme files verbatim (e.g. bundled web fonts // like .woff and their accompanying FONT_LICENSE.txt notices). extras, err := templates.ThemeExtraFiles(name) if err != nil { return fmt.Errorf("list %s extras: %w", name, err) } for _, f := range extras { if err := os.WriteFile(filepath.Join(dir, f.Name), f.Data, 0o644); err != nil { return fmt.Errorf("write %s/%s: %w", name, f.Name, err) } } return nil } func loadThemeMeta(name string) (themeMeta, error) { var m themeMeta b, err := templates.ThemeMeta(name) if err != nil { return m, fmt.Errorf("read theme meta %q: %w", name, err) } if err := json.Unmarshal(b, &m); err != nil { return m, fmt.Errorf("parse theme meta %q: %w", name, err) } m.HeaderHTML = headerHTMLWithVersion(m.HeaderHTML) return m, nil } func themeMetaJSON(name string) ([]byte, error) { m, err := loadThemeMeta(name) if err != nil { return nil, err } b, err := json.MarshalIndent(m, "", " ") if err != nil { return nil, fmt.Errorf("marshal theme meta %q: %w", name, err) } return b, nil } func headerHTMLWithVersion(header string) string { if strings.Contains(header, "sno-version") { return header } versionHTML := ` <span class="sno-version-sep" aria-hidden="true">·</span> <span class="sno-version">snonux v` + template.HTMLEscapeString(version.Version) + `</span>` hostClass := `class="logo-host"` if idx := strings.Index(header, hostClass); idx >= 0 { if end := strings.Index(header[idx:], "</p>"); end >= 0 { pos := idx + end return header[:pos] + versionHTML + header[pos:] } } return header + versionHTML } // allThemesJSON returns a JS array literal of all theme names. func allThemesJSON() (template.JS, error) { names, err := templates.ThemeNames() if err != nil { return "", err } b, err := json.Marshal(names) if err != nil { return "", err } return template.JS(b), nil //nolint:gosec // marshalled from a fixed string slice }