diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
| commit | 3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch) | |
| tree | 7d0ac51cfb41b4774db6292deeb0cc3dce93cf07 | |
| parent | 51f95f88ca78471a50b3fc62dbcea8edb609dc80 (diff) | |
add snonux static microblog generator
Full Go implementation with:
- txt/md/image/audio input processing, URL auto-linking in .txt files
- Paginated HTML output with Atom feed
- 11 visual themes: neon, terminal, synthwave, minimal, brutalist, paper,
aurora, matrix, ocean, retro, glass (selectable via --theme flag)
- Keyboard navigation (j/k/arrows, Enter modal, h/l page nav)
- Shared nav templates (navhints, navmodal, navscript) across all themes
- Magefile build automation; integration test suite covering all themes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
28 files changed, 2992 insertions, 0 deletions
diff --git a/Magefile.go b/Magefile.go new file mode 100644 index 0000000..e2908a5 --- /dev/null +++ b/Magefile.go @@ -0,0 +1,85 @@ +//go:build mage +// +build mage + +// Magefile provides build automation for the snonux microblog generator. +package main + +import ( + "fmt" + "os" + "os/exec" + + "github.com/magefile/mage/mg" +) + +// Build compiles the snonux binary for the current platform. +func Build() error { + fmt.Println("Building snonux...") + cmd := exec.Command("go", "build", "-o", "snonux", "./cmd/snonux") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Dev builds snonux with race detection enabled. Runs Vet and Lint first. +func Dev() error { + mg.Deps(Vet, Lint) + fmt.Println("Building with race detector...") + cmd := exec.Command("go", "build", "-race", "-o", "snonux", "./cmd/snonux") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Test runs the unit tests in all internal packages. +func Test() error { + fmt.Println("Running unit tests...") + cmd := exec.Command("go", "test", "./internal/...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// IntegrationTest runs the end-to-end integration tests. +func IntegrationTest() error { + fmt.Println("Running integration tests...") + cmd := exec.Command("go", "test", "-v", "./integrationtests/...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Vet runs go vet on all packages to catch common mistakes. +func Vet() error { + fmt.Println("Vetting...") + cmd := exec.Command("go", "vet", "./...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Lint runs golangci-lint on the codebase. +func Lint() error { + fmt.Println("Linting...") + cmd := exec.Command("golangci-lint", "run") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Generate builds snonux (if needed) and runs it to process any new inbox files +// and regenerate the full static site in ~/git/snonux.foo/dist. +func Generate() error { + mg.Deps(Build) + fmt.Println("Generating site...") + cmd := exec.Command("./snonux") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Clean removes the compiled binary. +func Clean() error { + fmt.Println("Cleaning...") + return os.Remove("snonux") +} diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go new file mode 100644 index 0000000..5a9c9d3 --- /dev/null +++ b/cmd/snonux/main.go @@ -0,0 +1,113 @@ +// Command snonux is the static microblog generator for snonux.foo. +// It processes new source files from the input directory into post directories, +// then regenerates all HTML pages and the Atom feed in the output directory. +// +// Usage: +// +// snonux --input ./inbox --output ./outdir [--base-url https://snonux.foo] +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/generator" + "codeberg.org/snonux/snonux/internal/processor" +) + +func main() { + cfg, err := parseFlags() + if err != nil { + log.Fatalf("error: %v", err) + } + + if err := run(cfg); err != nil { + log.Fatalf("error: %v", err) + } +} + +// parseFlags reads CLI flags and returns a validated Config. +func parseFlags() (*config.Config, error) { + cfg := &config.Config{} + + flag.StringVar(&cfg.InputDir, "input", "./inbox", "directory containing new source files to process") + flag.StringVar(&cfg.OutputDir, "output", "~/git/snonux.foo/dist", "root directory for generated static site output") + flag.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links") + flag.StringVar(&cfg.Theme, "theme", "neon", "visual theme: aurora, brutalist, glass, matrix, minimal, neon, ocean, paper, retro, synthwave, terminal") + flag.Parse() + + var err error + + cfg.InputDir, err = expandHome(cfg.InputDir) + if err != nil { + return nil, fmt.Errorf("input dir: %w", err) + } + + cfg.OutputDir, err = expandHome(cfg.OutputDir) + if err != nil { + return nil, fmt.Errorf("output dir: %w", err) + } + + if err := ensureDir(cfg.InputDir); err != nil { + return nil, fmt.Errorf("input dir: %w", err) + } + + if err := ensureDir(cfg.OutputDir); err != nil { + return nil, fmt.Errorf("output dir: %w", err) + } + + return cfg, nil +} + +// expandHome replaces a leading ~ with the current user's home directory. +func expandHome(path string) (string, error) { + if len(path) == 0 || path[0] != '~' { + return path, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + + return filepath.Join(home, path[1:]), nil +} + +// run executes both pipeline phases: process inputs, then regenerate pages. +func run(cfg *config.Config) error { + processed, err := processor.Run(cfg) + if err != nil { + return fmt.Errorf("processing input files: %w", err) + } + + log.Printf("processed %d new post(s) from %s", processed, cfg.InputDir) + + if err := generator.Run(cfg); err != nil { + return fmt.Errorf("generating site: %w", err) + } + + log.Printf("site regenerated in %s", cfg.OutputDir) + + return nil +} + +// ensureDir creates dir if it does not exist, or returns an error if path +// exists but is not a directory. +func ensureDir(dir string) error { + info, err := os.Stat(dir) + if os.IsNotExist(err) { + return os.MkdirAll(dir, 0o755) + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s exists but is not a directory", dir) + } + + return nil +} @@ -0,0 +1,9 @@ +module codeberg.org/snonux/snonux + +go 1.25.8 + +require ( + github.com/magefile/mage v1.17.1 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + golang.org/x/image v0.38.0 // indirect +) @@ -0,0 +1,6 @@ +github.com/magefile/mage v1.17.1 h1:F1d2lnLSlbQDM0Plq6Ac4NtaHxkxTK8t5nrMY9SkoNA= +github.com/magefile/mage v1.17.1/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go new file mode 100644 index 0000000..634971e --- /dev/null +++ b/integrationtests/integration_test.go @@ -0,0 +1,382 @@ +// Package integrationtests runs end-to-end tests of the snonux generator pipeline. +// Each test creates temporary input/output directories, places fixture files, runs +// the full processor+generator pipeline, and asserts the expected outputs. +package integrationtests + +import ( + "encoding/xml" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/generator" + "codeberg.org/snonux/snonux/internal/processor" +) + +// runPipeline executes both pipeline stages and returns the config used. +func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config { + t.Helper() + + cfg := &config.Config{ + InputDir: inputDir, + OutputDir: outputDir, + BaseURL: "https://snonux.foo", + Theme: "neon", + } + + _, err := processor.Run(cfg) + if err != nil { + t.Fatalf("processor.Run: %v", err) + } + + if err := generator.Run(cfg); err != nil { + t.Fatalf("generator.Run: %v", err) + } + + return cfg +} + +// makeDirs creates temporary input and output directories for a test. +func makeDirs(t *testing.T) (inputDir, outputDir string) { + t.Helper() + + base := t.TempDir() + inputDir = filepath.Join(base, "inbox") + outputDir = filepath.Join(base, "outdir") + + if err := os.MkdirAll(inputDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + + return inputDir, outputDir +} + +// readFile is a helper that reads a file and fails the test on error. +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + return string(data) +} + +// assertContains fails the test if content does not contain substr. +func assertContains(t *testing.T, content, substr, label string) { + t.Helper() + + if !strings.Contains(content, substr) { + t.Errorf("%s: expected to contain %q\ngot:\n%s", label, substr, content[:min(len(content), 500)]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// TestTxtInput verifies plain text files are converted to posts. +func TestTxtInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("Hello, Nexus!"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + // Source file should have been removed after processing. + if _, err := os.Stat(filepath.Join(inputDir, "hello.txt")); !os.IsNotExist(err) { + t.Error("source file should have been deleted from input dir") + } + + // A post directory should exist under outdir/posts/. + entries, err := os.ReadDir(filepath.Join(outputDir, "posts")) + if err != nil { + t.Fatalf("read posts dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 post dir, got %d", len(entries)) + } + + // index.html must contain the post text. + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "Hello, Nexus!", "index.html") +} + +// TestMarkdownInput verifies Markdown files are converted to HTML. +func TestMarkdownInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + md := "# Hello Nexus\n\nThis is **bold** text." + if err := os.WriteFile(filepath.Join(inputDir, "post.md"), []byte(md), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "<strong>bold</strong>", "index.html markdown bold") + assertContains(t, index, "<h1>", "index.html markdown h1") +} + +// TestImageInput verifies image files are processed and embedded in pages. +func TestImageInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + writeSamplePNG(t, filepath.Join(inputDir, "photo.png")) + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `<img`, "index.html image tag") + assertContains(t, index, `image.jpg`, "index.html image filename") + + // Converted JPEG should exist in the post asset dir. + postDirs, _ := os.ReadDir(filepath.Join(outputDir, "posts")) + if len(postDirs) != 1 { + t.Fatalf("expected 1 post, got %d", len(postDirs)) + } + imgPath := filepath.Join(outputDir, "posts", postDirs[0].Name(), "image.jpg") + if _, err := os.Stat(imgPath); err != nil { + t.Errorf("expected image.jpg in post dir: %v", err) + } +} + +// TestAudioInput verifies .mp3 files are copied and an audio element is generated. +func TestAudioInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + // Write a minimal non-empty file as a stand-in for MP3 content. + if err := os.WriteFile(filepath.Join(inputDir, "track.mp3"), []byte("ID3fake"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `<audio`, "index.html audio tag") + assertContains(t, index, `track.mp3`, "index.html audio filename") +} + +// TestMarkdownWithImage verifies that a Markdown post referencing a local image +// copies the image into the post dir and updates the src path. +func TestMarkdownWithImage(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + md := "Look at this:\n\n\n" + if err := os.WriteFile(filepath.Join(inputDir, "post.md"), []byte(md), 0o644); err != nil { + t.Fatal(err) + } + + writeSamplePNG(t, filepath.Join(inputDir, "photo.png")) + + runPipeline(t, inputDir, outputDir) + + postDirs, _ := os.ReadDir(filepath.Join(outputDir, "posts")) + if len(postDirs) != 1 { + t.Fatalf("expected 1 post, got %d", len(postDirs)) + } + + // The referenced image should be copied into the post dir. + imgPath := filepath.Join(outputDir, "posts", postDirs[0].Name(), "photo.png") + if _, err := os.Stat(imgPath); err != nil { + t.Errorf("expected photo.png in post dir: %v", err) + } +} + +// TestPagination verifies that 45 posts are split across two pages (42 + 3). +func TestPagination(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 45; i++ { + name := fmt.Sprintf("post%02d.txt", i) + content := fmt.Sprintf("Post number %d", i) + if err := os.WriteFile(filepath.Join(inputDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + // index.html should exist and contain 42 posts. + index := readFile(t, filepath.Join(outputDir, "index.html")) + if count := strings.Count(index, `class="post"`); count != 42 { + t.Errorf("index.html: expected 42 posts, got %d", count) + } + + // page2.html should exist and contain 3 posts. + page2 := readFile(t, filepath.Join(outputDir, "page2.html")) + if count := strings.Count(page2, `class="post"`); count != 3 { + t.Errorf("page2.html: expected 3 posts, got %d", count) + } +} + +// TestPaginationNavLinks verifies prev/next navigation links are positioned correctly. +func TestPaginationNavLinks(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 45; i++ { + if err := os.WriteFile(filepath.Join(inputDir, fmt.Sprintf("p%02d.txt", i)), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + // index.html (page 1) has no prev, should have next link (page2.html). + assertContains(t, index, "page2.html", "index.html next link") + if strings.Contains(index, "NEWER TRANSMISSIONS") { + t.Error("index.html should not have a prev-page link") + } + + page2 := readFile(t, filepath.Join(outputDir, "page2.html")) + // page2.html should have a prev link (index.html) and no next. + assertContains(t, page2, "NEWER TRANSMISSIONS", "page2.html prev link") + if strings.Contains(page2, "OLDER TRANSMISSIONS") { + t.Error("page2.html should not have a next-page link") + } +} + +// TestAtomFeed verifies that atom.xml is well-formed and contains ≤42 entries. +func TestAtomFeed(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 5; i++ { + if err := os.WriteFile(filepath.Join(inputDir, fmt.Sprintf("p%d.txt", i)), []byte("feed post"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + atomPath := filepath.Join(outputDir, "atom.xml") + data, err := os.ReadFile(atomPath) + if err != nil { + t.Fatalf("read atom.xml: %v", err) + } + + // Validate well-formed XML. + var feed struct { + XMLName xml.Name `xml:"feed"` + Entries []struct { + Title string `xml:"title"` + } `xml:"entry"` + } + if err := xml.Unmarshal(data, &feed); err != nil { + t.Fatalf("atom.xml not valid XML: %v", err) + } + + if len(feed.Entries) != 5 { + t.Errorf("expected 5 entries in atom.xml, got %d", len(feed.Entries)) + } +} + +// TestInputCleanup verifies all source files are removed from the input dir. +func TestInputCleanup(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { + if err := os.WriteFile(filepath.Join(inputDir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + entries, _ := os.ReadDir(inputDir) + if len(entries) != 0 { + t.Errorf("input dir should be empty after processing, got %d files", len(entries)) + } +} + +// TestKeyboardNavJS verifies that the generated HTML includes navigation attributes. +func TestKeyboardNavJS(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "nav.txt"), []byte("nav test"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `data-index="0"`, "index.html data-index attribute") + assertContains(t, index, `.post-active`, "index.html .post-active CSS") + assertContains(t, index, `playNavSound`, "index.html playNavSound function") +} + +// TestThemeSelection verifies that every registered theme renders a valid +// index.html containing core structural elements (post text, nav script). +func TestThemeSelection(t *testing.T) { + themes := []string{ + "aurora", "brutalist", "glass", "matrix", "minimal", + "neon", "ocean", "paper", "retro", "synthwave", "terminal", + } + + for _, theme := range themes { + theme := theme // capture for parallel sub-test + + t.Run(theme, func(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("theme test post"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + InputDir: inputDir, + OutputDir: outputDir, + BaseURL: "https://snonux.foo", + Theme: theme, + } + + if _, err := processor.Run(cfg); err != nil { + t.Fatalf("processor.Run: %v", err) + } + if err := generator.Run(cfg); err != nil { + t.Fatalf("generator.Run for theme %q: %v", theme, err) + } + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "theme test post", "post text") + assertContains(t, index, "playNavSound", "nav JS") + assertContains(t, index, `data-index="0"`, "data-index attribute") + }) + } +} + +// writeSamplePNG writes a small 10×10 solid-colour PNG to path. +func writeSamplePNG(t *testing.T, path string) { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + img.Set(x, y, color.RGBA{R: 0, G: 245, B: 255, A: 255}) + } + } + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fd6e560 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,26 @@ +// Package config holds the shared configuration for the snonux generator. +// All values are derived from CLI flags with sensible defaults. +package config + +const ( + // PostsPerPage is the maximum number of blog posts rendered on a single HTML page. + PostsPerPage = 42 +) + +// Config carries the runtime configuration for the generator pipeline. +type Config struct { + // InputDir is where new source files (txt, md, images, audio) are read from. + InputDir string + + // OutputDir is the root of the static site: index.html, pageN.html, atom.xml, + // and the posts/ subdirectory all live here. + OutputDir string + + // BaseURL is the canonical site URL, used in the Atom feed links. + // Example: "https://snonux.foo" + BaseURL string + + // Theme selects the visual style for generated HTML pages. + // Defaults to "neon". Run with --help to see all available themes. + Theme string +} diff --git a/internal/generator/atom.go b/internal/generator/atom.go new file mode 100644 index 0000000..259301c --- /dev/null +++ b/internal/generator/atom.go @@ -0,0 +1,104 @@ +package generator + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// atomFeed is the root element of an Atom 1.0 feed document. +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + XMLNS string `xml:"xmlns,attr"` + Title string `xml:"title"` + Link atomLink `xml:"link"` + Updated string `xml:"updated"` + ID string `xml:"id"` + Entries []atomEntry `xml:"entry"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type atomEntry struct { + Title string `xml:"title"` + Link atomLink `xml:"link"` + ID string `xml:"id"` + Updated string `xml:"updated"` + Content atomContent `xml:"content"` +} + +type atomContent struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +// generateAtom writes atom.xml to cfg.OutputDir containing the most recent +// min(len(posts), config.PostsPerPage) entries. +func generateAtom(posts []*post.Post, cfg *config.Config) error { + limit := config.PostsPerPage + if len(posts) < limit { + limit = len(posts) + } + + recent := posts[:limit] + entries := buildAtomEntries(recent, cfg.BaseURL) + + updated := time.Now().UTC().Format(time.RFC3339) + if len(recent) > 0 { + updated = recent[0].Timestamp.UTC().Format(time.RFC3339) + } + + feed := atomFeed{ + XMLNS: "http://www.w3.org/2005/Atom", + Title: "snonux.foo", + Link: atomLink{Href: cfg.BaseURL + "/"}, + Updated: updated, + ID: cfg.BaseURL + "/", + Entries: entries, + } + + return writeAtomFile(feed, filepath.Join(cfg.OutputDir, "atom.xml")) +} + +// buildAtomEntries converts a slice of posts into Atom entry elements. +func buildAtomEntries(posts []*post.Post, baseURL string) []atomEntry { + entries := make([]atomEntry, 0, len(posts)) + + for _, p := range posts { + entryURL := fmt.Sprintf("%s/posts/%s/", baseURL, p.ID) + entry := atomEntry{ + Title: fmt.Sprintf("Post %s", p.ID), + Link: atomLink{Href: entryURL, Rel: "alternate"}, + ID: entryURL, + Updated: p.Timestamp.UTC().Format(time.RFC3339), + Content: atomContent{Type: "html", Value: p.Content}, + } + entries = append(entries, entry) + } + + return entries +} + +// writeAtomFile marshals feed to XML and writes it to path with XML declaration. +func writeAtomFile(feed atomFeed, path string) error { + data, err := xml.MarshalIndent(feed, "", " ") + if err != nil { + return fmt.Errorf("marshal atom feed: %w", err) + } + + content := append([]byte(xml.Header), data...) + + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("write atom.xml: %w", err) + } + + return nil +} 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) + } + |
