From 3e61d09873065f5342efc414ee3ea0d5fdc4c767 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 9 Apr 2026 20:44:58 +0300 Subject: 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 --- Magefile.go | 85 ++++++++ cmd/snonux/main.go | 113 ++++++++++ go.mod | 9 + go.sum | 6 + integrationtests/integration_test.go | 382 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 26 +++ internal/generator/atom.go | 104 +++++++++ internal/generator/generator.go | 188 +++++++++++++++++ internal/generator/shared.go | 100 +++++++++ internal/generator/templates.go | 5 + internal/generator/theme_aurora.go | 114 ++++++++++ internal/generator/theme_brutalist.go | 97 +++++++++ internal/generator/theme_glass.go | 123 +++++++++++ internal/generator/theme_matrix.go | 102 +++++++++ internal/generator/theme_minimal.go | 96 +++++++++ internal/generator/theme_neon.go | 224 ++++++++++++++++++++ internal/generator/theme_ocean.go | 105 ++++++++++ internal/generator/theme_paper.go | 98 +++++++++ internal/generator/theme_retro.go | 105 ++++++++++ internal/generator/theme_synthwave.go | 111 ++++++++++ internal/generator/theme_terminal.go | 101 +++++++++ internal/generator/themes.go | 44 ++++ internal/post/post.go | 84 ++++++++ internal/processor/audio.go | 49 +++++ internal/processor/image.go | 116 +++++++++++ internal/processor/markdown.go | 68 ++++++ internal/processor/processor.go | 234 +++++++++++++++++++++ internal/processor/txt.go | 103 +++++++++ 28 files changed, 2992 insertions(+) create mode 100644 Magefile.go create mode 100644 cmd/snonux/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 integrationtests/integration_test.go create mode 100644 internal/config/config.go create mode 100644 internal/generator/atom.go create mode 100644 internal/generator/generator.go create mode 100644 internal/generator/shared.go create mode 100644 internal/generator/templates.go create mode 100644 internal/generator/theme_aurora.go create mode 100644 internal/generator/theme_brutalist.go create mode 100644 internal/generator/theme_glass.go create mode 100644 internal/generator/theme_matrix.go create mode 100644 internal/generator/theme_minimal.go create mode 100644 internal/generator/theme_neon.go create mode 100644 internal/generator/theme_ocean.go create mode 100644 internal/generator/theme_paper.go create mode 100644 internal/generator/theme_retro.go create mode 100644 internal/generator/theme_synthwave.go create mode 100644 internal/generator/theme_terminal.go create mode 100644 internal/generator/themes.go create mode 100644 internal/post/post.go create mode 100644 internal/processor/audio.go create mode 100644 internal/processor/image.go create mode 100644 internal/processor/markdown.go create mode 100644 internal/processor/processor.go create mode 100644 internal/processor/txt.go 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, "bold", "index.html markdown bold") + assertContains(t, index, "

", "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, ` 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) + } + + 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 +{{end}} +` diff --git a/internal/generator/templates.go b/internal/generator/templates.go new file mode 100644 index 0000000..5186794 --- /dev/null +++ b/internal/generator/templates.go @@ -0,0 +1,5 @@ +package generator + +// HTML templates have moved to per-theme files (theme_*.go). +// Shared sub-templates (navhints, navmodal, navscript) are in shared.go. +// The theme registry and selection logic are in themes.go. diff --git a/internal/generator/theme_aurora.go b/internal/generator/theme_aurora.go new file mode 100644 index 0000000..9475320 --- /dev/null +++ b/internal/generator/theme_aurora.go @@ -0,0 +1,114 @@ +package generator + +// auroraTemplate is a dark navy theme with a CSS-animated aurora borealis +// effect — shifting green/purple/teal gradients across the background sky. +const auroraTemplate = ` + + + + + snonux.foo ✦ AURORA + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_brutalist.go b/internal/generator/theme_brutalist.go new file mode 100644 index 0000000..214c103 --- /dev/null +++ b/internal/generator/theme_brutalist.go @@ -0,0 +1,97 @@ +package generator + +// brutalistTemplate is a raw brutalist theme — pure black, thick white borders, +// Impact font, red as the only accent. No rounded corners anywhere. +const brutalistTemplate = ` + + + + + SNONUX.FOO + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@SNONUX
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_glass.go b/internal/generator/theme_glass.go new file mode 100644 index 0000000..520f9b0 --- /dev/null +++ b/internal/generator/theme_glass.go @@ -0,0 +1,123 @@ +package generator + +// glassTemplate is a glassmorphism theme — semi-transparent frosted panels +// using backdrop-filter:blur over a blurred gradient background. +// Light mode with subtle purple/blue gradient blobs and white glass cards. +const glassTemplate = ` + + + + + snonux.foo · glass + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_matrix.go b/internal/generator/theme_matrix.go new file mode 100644 index 0000000..6d8b531 --- /dev/null +++ b/internal/generator/theme_matrix.go @@ -0,0 +1,102 @@ +package generator + +// matrixTemplate is a hacker-style theme inspired by The Matrix — black +// background, bright matrix-green (#00ff41) text, monospace throughout, +// no decorations beyond a faint scanline overlay. +const matrixTemplate = ` + + + + + snonux.foo // MATRIX + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_minimal.go b/internal/generator/theme_minimal.go new file mode 100644 index 0000000..ebad091 --- /dev/null +++ b/internal/generator/theme_minimal.go @@ -0,0 +1,96 @@ +package generator + +// minimalTemplate is a clean white theme — system font, subtle borders, +// no animations or decorations. Maximum readability. +const minimalTemplate = ` + + + + + snonux.foo + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_neon.go b/internal/generator/theme_neon.go new file mode 100644 index 0000000..3197d6f --- /dev/null +++ b/internal/generator/theme_neon.go @@ -0,0 +1,224 @@ +package generator + +// neonTemplate is the cyberpunk neon theme — dark background, Three.js 3D orb +// and rings, cyan/magenta/yellow palette, Orbitron font. +// Keyboard nav and modal HTML are injected via the shared navDefs sub-templates. +const neonTemplate = ` + + + + + snonux.foo • NEON NEXUS + + + + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}} + + {{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}} + + {{end}} +
+
+ {{template "navmodal" .}} + + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_ocean.go b/internal/generator/theme_ocean.go new file mode 100644 index 0000000..422cf0b --- /dev/null +++ b/internal/generator/theme_ocean.go @@ -0,0 +1,105 @@ +package generator + +// oceanTemplate is a deep-ocean theme — dark navy/midnight blue background, +// teal/aqua/seafoam accents, subtle wave gradient at the bottom. +const oceanTemplate = ` + + + + + snonux.foo ~ OCEAN + + + +
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_paper.go b/internal/generator/theme_paper.go new file mode 100644 index 0000000..551e224 --- /dev/null +++ b/internal/generator/theme_paper.go @@ -0,0 +1,98 @@ +package generator + +// paperTemplate is a warm vintage newspaper theme — Georgia serif, sepia tones, +// subtle texture simulation via CSS, no animations. +const paperTemplate = ` + + + + + snonux.foo — the microblog + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_retro.go b/internal/generator/theme_retro.go new file mode 100644 index 0000000..82dc605 --- /dev/null +++ b/internal/generator/theme_retro.go @@ -0,0 +1,105 @@ +package generator + +// retroTemplate is an amber DOS terminal theme — black background, amber +// phosphor (#ffb000) text, monospace throughout, no decorations. +// Distinct from terminal.go (green) — this one evokes vintage PC monitors. +const retroTemplate = ` + + + + + SNONUX.FOO // RETRO + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@SNONUX
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_synthwave.go b/internal/generator/theme_synthwave.go new file mode 100644 index 0000000..8798d18 --- /dev/null +++ b/internal/generator/theme_synthwave.go @@ -0,0 +1,111 @@ +package generator + +// synthwaveTemplate is the 80s retrowave theme — dark purple sky, CSS perspective +// grid floor, hot pink/orange accents, Russo One font. +const synthwaveTemplate = ` + + + + + snonux.foo ⊕ SYNTHWAVE + + + + + +
+
+
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/theme_terminal.go b/internal/generator/theme_terminal.go new file mode 100644 index 0000000..075ec25 --- /dev/null +++ b/internal/generator/theme_terminal.go @@ -0,0 +1,101 @@ +package generator + +// terminalTemplate is the green phosphor CRT terminal theme. +// Monospace throughout, scanline overlay via CSS, no external dependencies. +const terminalTemplate = ` + + + + + snonux.foo // TERMINAL + + + +
+
+ + +
+ {{template "navhints" .}} +
+ {{if .PrevPage}}{{end}} + {{range $i, $post := .Posts}} +
+
+
@snonux
+
{{$post.FormattedTime}}
+
+
{{$post.ContentHTML}}
+
+ {{end}} + {{if .NextPage}}{{end}} +
+
+ {{template "navmodal" .}} + {{template "navscript" .}} + +` diff --git a/internal/generator/themes.go b/internal/generator/themes.go new file mode 100644 index 0000000..8de6193 --- /dev/null +++ b/internal/generator/themes.go @@ -0,0 +1,44 @@ +package generator + +// themeRegistry maps theme names to their HTML template strings. +// Each template must use {{template "navhints" .}}, {{template "navmodal" .}}, +// and {{template "navscript" .}} — these are defined in shared.go (navDefs). +var themeRegistry = map[string]string{ + "neon": neonTemplate, + "terminal": terminalTemplate, + "synthwave": synthwaveTemplate, + "minimal": minimalTemplate, + "brutalist": brutalistTemplate, + "paper": paperTemplate, + "aurora": auroraTemplate, + "matrix": matrixTemplate, + "ocean": oceanTemplate, + "retro": retroTemplate, + "glass": glassTemplate, +} + +// getTheme returns the HTML template string for the given theme name. +// Falls back to the neon theme if the name is unknown. +func getTheme(name string) string { + if t, ok := themeRegistry[name]; ok { + return t + } + return neonTemplate +} + +// ListThemes returns a sorted list of all available theme names. +func ListThemes() []string { + names := make([]string, 0, len(themeRegistry)) + for k := range themeRegistry { + names = append(names, k) + } + // Sort for deterministic output in --help text. + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[i] > names[j] { + names[i], names[j] = names[j], names[i] + } + } + } + return names +} diff --git a/internal/post/post.go b/internal/post/post.go new file mode 100644 index 0000000..cdef546 --- /dev/null +++ b/internal/post/post.go @@ -0,0 +1,84 @@ +// Package post defines the Post data model and its JSON serialisation format. +// Each post is stored as post.json inside its own directory under outdir/posts/. +// This allows pages to be re-generated without re-processing the original inputs. +package post + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Type enumerates the supported input content types. +type Type string + +const ( + TypeText Type = "text" + TypeMarkdown Type = "markdown" + TypeImage Type = "image" + TypeAudio Type = "audio" +) + +// Post represents a single microblog entry. +// It is persisted as post.json in outdir/posts//. +type Post struct { + // ID is the unique directory-name-safe timestamp, e.g. "2026-04-09-143022". + ID string `json:"id"` + + // Timestamp is the moment the post was processed (UTC). + Timestamp time.Time `json:"timestamp"` + + // PostType determines how the content was generated and how it should be rendered. + PostType Type `json:"type"` + + // Content is the pre-rendered HTML snippet for this post (without outer post-card wrapper). + Content string `json:"content"` + + // Assets lists filenames (not paths) of any asset files stored alongside post.json. + Assets []string `json:"assets,omitempty"` +} + +// Save writes the post as post.json into dir. +func (p *Post) Save(dir string) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("marshal post %s: %w", p.ID, err) + } + + path := filepath.Join(dir, "post.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write post.json for %s: %w", p.ID, err) + } + + return nil +} + +// Load reads and parses post.json from dir. +func Load(dir string) (*Post, error) { + path := filepath.Join(dir, "post.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read post.json in %s: %w", dir, err) + } + + var p Post + if err := json.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("unmarshal post.json in %s: %w", dir, err) + } + + return &p, nil +} + +// NewID generates a unique post ID from the given time. +// Format: YYYY-MM-DD-HHmmss, optionally suffixed with -N for collisions. +func NewID(t time.Time, suffix int) string { + base := t.UTC().Format("2006-01-02-150405") + if suffix == 0 { + return base + } + + return fmt.Sprintf("%s-%d", base, suffix) +} diff --git a/internal/processor/audio.go b/internal/processor/audio.go new file mode 100644 index 0000000..98aedcf --- /dev/null +++ b/internal/processor/audio.go @@ -0,0 +1,49 @@ +package processor + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// processAudio copies an .mp3 file into destDir and returns an HTML