summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
committerPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
commit3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch)
tree7d0ac51cfb41b4774db6292deeb0cc3dce93cf07
parent51f95f88ca78471a50b3fc62dbcea8edb609dc80 (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>
-rw-r--r--Magefile.go85
-rw-r--r--cmd/snonux/main.go113
-rw-r--r--go.mod9
-rw-r--r--go.sum6
-rw-r--r--integrationtests/integration_test.go382
-rw-r--r--internal/config/config.go26
-rw-r--r--internal/generator/atom.go104
-rw-r--r--internal/generator/generator.go188
-rw-r--r--internal/generator/shared.go100
-rw-r--r--internal/generator/templates.go5
-rw-r--r--internal/generator/theme_aurora.go114
-rw-r--r--internal/generator/theme_brutalist.go97
-rw-r--r--internal/generator/theme_glass.go123
-rw-r--r--internal/generator/theme_matrix.go102
-rw-r--r--internal/generator/theme_minimal.go96
-rw-r--r--internal/generator/theme_neon.go224
-rw-r--r--internal/generator/theme_ocean.go105
-rw-r--r--internal/generator/theme_paper.go98
-rw-r--r--internal/generator/theme_retro.go105
-rw-r--r--internal/generator/theme_synthwave.go111
-rw-r--r--internal/generator/theme_terminal.go101
-rw-r--r--internal/generator/themes.go44
-rw-r--r--internal/post/post.go84
-rw-r--r--internal/processor/audio.go49
-rw-r--r--internal/processor/image.go116
-rw-r--r--internal/processor/markdown.go68
-rw-r--r--internal/processor/processor.go234
-rw-r--r--internal/processor/txt.go103
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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9bb1d00
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7c2216e
--- /dev/null
+++ b/go.sum
@@ -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![cool pic](photo.png)\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)
+ }
+