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 +