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) + } + + 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 <script> block as a JS value. +func jsonStringOrNull(s string) template.JS { + if s == "" { + return "null" + } + + b, _ := json.Marshal(s) + + return template.JS(strings.TrimSpace(string(b))) //nolint:gosec // filename is tool-generated +} diff --git a/internal/generator/shared.go b/internal/generator/shared.go new file mode 100644 index 0000000..eed4de3 --- /dev/null +++ b/internal/generator/shared.go @@ -0,0 +1,100 @@ +package generator + +// navDefs is appended to every theme template when parsing. +// It defines three named sub-templates shared across all themes: +// - "navhints" — keyboard shortcut hint bar HTML +// - "navmodal" — full-screen expanded-post modal HTML +// - "navscript" — keyboard navigation JavaScript +// +// Each theme calls {{template "navhints" .}}, {{template "navmodal" .}}, and +// {{template "navscript" .}} at the appropriate points in its HTML. +// All CSS for these elements (colours, borders, backdrop) lives in each theme +// so themes remain self-contained and independently styled. +const navDefs = ` +{{define "navhints"}} +<div class="nav-hints" aria-label="keyboard shortcuts"> + <span><kbd>j</kbd><kbd>k</kbd> or <kbd>↑</kbd><kbd>↓</kbd> select post</span> + <span><kbd>Enter</kbd> expand</span> + <span><kbd>Esc</kbd> close</span> + <span><kbd>h</kbd><kbd>l</kbd> or <kbd>←</kbd><kbd>→</kbd> change page</span> +</div> +{{end}} + +{{define "navmodal"}} +<div class="post-modal" id="post-modal"> + <div class="modal-inner"> + <button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button> + <div id="modal-content"></div> + </div> +</div> +{{end}} + +{{define "navscript"}} +<script> + // === KEYBOARD NAVIGATION === + // j / ArrowDown → next post k / ArrowUp → previous post + // h / ArrowLeft → previous page l / ArrowRight → next page + // Enter → expand modal Esc → close modal + const posts = document.querySelectorAll('.post'); + let currentIndex = posts.length > 0 ? 0 : -1; + const prevPageURL = {{.PrevPageJSON}}; + const nextPageURL = {{.NextPageJSON}}; + + if (currentIndex >= 0) selectPost(0); + + function selectPost(index) { + if (posts.length === 0) return; + if (currentIndex >= 0) posts[currentIndex].classList.remove('post-active'); + currentIndex = Math.max(0, Math.min(index, posts.length - 1)); + posts[currentIndex].classList.add('post-active'); + posts[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + playNavSound(); + } + + // playNavSound generates a short beep via the Web Audio API. + // A fresh AudioContext per call avoids state issues across navigations. + function playNavSound() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.frequency.value = 220; osc.type = 'sine'; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); + osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.08); + } catch (_) {} + } + + function openModal() { + if (currentIndex < 0) return; + document.getElementById('modal-content').innerHTML = + posts[currentIndex].querySelector('.post-text').innerHTML; + document.getElementById('post-modal').classList.add('active'); + } + + function closeModal() { + document.getElementById('post-modal').classList.remove('active'); + } + + document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (document.getElementById('post-modal').classList.contains('active')) { + if (e.key === 'Escape') { closeModal(); e.preventDefault(); } + return; + } + switch (e.key) { + case 'j': case 'ArrowDown': selectPost(currentIndex + 1); e.preventDefault(); break; + case 'k': case 'ArrowUp': selectPost(currentIndex - 1); e.preventDefault(); break; + case 'h': case 'ArrowLeft': + if (prevPageURL) { playNavSound(); window.location.href = prevPageURL; } + e.preventDefault(); break; + case 'l': case 'ArrowRight': + if (nextPageURL) { playNavSound(); window.location.href = nextPageURL; } + e.preventDefault(); break; + case 'Enter': openModal(); e.preventDefault(); break; + } + }); +</script> +{{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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo ✦ AURORA</title> + <style> + :root { --green:#00ffb3; --teal:#00cfe8; --purple:#c084fc; --navy:#050d1a; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--navy); + color:#e0f8f0; overflow:hidden; height:100vh; } + /* Animated aurora bands */ + @keyframes aurora1 { 0%,100%{opacity:0.18;transform:scaleX(1) translateY(0)} 50%{opacity:0.28;transform:scaleX(1.15) translateY(-14px)} } + @keyframes aurora2 { 0%,100%{opacity:0.12;transform:scaleX(1) translateY(0)} 50%{opacity:0.22;transform:scaleX(0.88) translateY(10px)} } + @keyframes aurora3 { 0%,100%{opacity:0.10;transform:scaleX(1) skewY(0deg)} 50%{opacity:0.18;transform:scaleX(1.08) skewY(2deg)} } + .aurora-bg { position:fixed; inset:0; z-index:0; overflow:hidden; } + .aurora-bg::before { content:''; position:absolute; left:-20%; top:5%; width:140%; height:45%; + background:radial-gradient(ellipse,rgba(0,255,179,0.38) 0%,rgba(0,207,232,0.22) 40%,transparent 70%); + filter:blur(40px); animation:aurora1 12s ease-in-out infinite; } + .aurora-bg::after { content:''; position:absolute; left:10%; top:20%; width:120%; height:55%; + background:radial-gradient(ellipse,rgba(192,132,252,0.28) 0%,rgba(0,255,179,0.18) 45%,transparent 70%); + filter:blur(50px); animation:aurora2 16s ease-in-out infinite; } + .aurora-band3 { position:fixed; left:-10%; top:35%; width:130%; height:40%; z-index:0; + background:radial-gradient(ellipse,rgba(0,207,232,0.22) 0%,rgba(192,132,252,0.14) 50%,transparent 75%); + filter:blur(45px); animation:aurora3 20s ease-in-out infinite; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(5,13,26,0.78); backdrop-filter:blur(14px); + border-bottom:1px solid rgba(0,255,179,0.25); display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2rem; font-weight:800; background:linear-gradient(90deg,var(--green),var(--teal)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; } + .logo-title h1 { font-size:1.5rem; font-weight:700; color:#e0f8f0; letter-spacing:1px; } + .logo-title .subtitle { font-size:0.75rem; color:rgba(224,248,240,0.55); margin-top:2px; } + .logo-title .subtitle a { color:var(--green); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--green); } + .transmit-btn { border:1px solid var(--teal); color:var(--teal); padding:9px 20px; + border-radius:20px; text-decoration:none; font-size:0.85rem; + transition:all 0.2s; } + .transmit-btn:hover { background:var(--teal); color:var(--navy); } + .nav-hints { background:rgba(5,13,26,0.6); border-bottom:1px solid rgba(0,255,179,0.15); + color:rgba(224,248,240,0.45); padding:5px 28px; display:flex; gap:18px; + font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(0,255,179,0.1); border:1px solid rgba(0,255,179,0.35); + color:var(--green); border-radius:3px; padding:0 5px; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:20px 28px; + scrollbar-width:thin; scrollbar-color:var(--green) var(--navy); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid var(--teal); color:var(--teal); padding:8px 20px; + border-radius:20px; text-decoration:none; font-size:0.82rem; letter-spacing:1px; } + .page-nav a:hover { background:var(--teal); color:var(--navy); } + .post { background:rgba(5,20,35,0.72); border:1px solid rgba(0,255,179,0.2); border-radius:10px; + padding:20px; margin-bottom:14px; cursor:pointer; + transition:all 0.25s; backdrop-filter:blur(6px); } + .post:hover { border-color:var(--green); box-shadow:0 0 20px rgba(0,255,179,0.2); transform:translateY(-2px); } + .post-active { border-color:var(--purple) !important; background:rgba(15,5,40,0.9) !important; + box-shadow:0 0 24px rgba(192,132,252,0.35),inset 3px 0 0 var(--purple) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:var(--teal); font-family:monospace; font-size:0.8rem; } + .post-text { line-height:1.65; font-size:0.95rem; } + .post-text a { color:var(--green); text-decoration:none; } + .post-text a:hover { text-shadow:0 0 8px var(--green); } + .post-image { max-width:100%; border-radius:8px; margin-top:10px; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(5,13,26,0.95); backdrop-filter:blur(20px); + overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:rgba(5,20,40,0.97); + border:1px solid var(--green); border-radius:12px; + box-shadow:0 0 60px rgba(0,255,179,0.25); padding:40px; } + .modal-close { float:right; background:none; border:none; color:var(--teal); + font-size:0.9rem; cursor:pointer; letter-spacing:1px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} } + </style> +</head> +<body> + <div class="aurora-bg"></div> + <div class="aurora-band3"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>SNONUX.FOO</title> + <style> + :root { --red:#ff2200; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:Impact,'Arial Narrow',Arial,sans-serif; + background:#000; color:#fff; overflow:hidden; height:100vh; } + .overlay { height:100vh; display:flex; flex-direction:column; } + header { padding:14px 24px; background:#000; border-bottom:4px solid #fff; + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:16px; } + .logo-mark { font-size:2.8rem; color:var(--red); line-height:1; } + .logo-title h1 { font-size:2rem; color:#fff; letter-spacing:0; line-height:1; } + .logo-title .subtitle { font-size:0.78rem; color:#888; margin-top:3px; + font-family:'Courier New',monospace; } + .logo-title .subtitle a { color:var(--red); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:3px solid var(--red); color:var(--red); padding:10px 20px; + border-radius:0; text-decoration:none; font-family:Impact; font-size:1.05rem; + letter-spacing:2px; transition:all 0.1s; } + .transmit-btn:hover { background:var(--red); color:#000; } + .nav-hints { background:#111; border-bottom:2px solid #333; color:#888; + padding:5px 24px; display:flex; gap:18px; font-family:'Courier New',monospace; + font-size:0.7rem; flex-wrap:wrap; } + .nav-hints kbd { background:#000; border:1px solid #555; color:#fff; + border-radius:0; padding:0 5px; margin:0 2px; font-size:0.7rem; } + .content { flex:1; overflow-y:auto; padding:20px 24px; + scrollbar-width:thin; scrollbar-color:#fff #000; } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:3px solid #fff; color:#fff; padding:9px 22px; + border-radius:0; text-decoration:none; font-family:Impact; + font-size:1rem; letter-spacing:2px; } + .page-nav a:hover { background:#fff; color:#000; } + .post { background:#000; border:3px solid #fff; border-radius:0; + padding:20px 22px; margin-bottom:14px; cursor:pointer; + transition:border-color 0.1s,background 0.1s; } + .post:hover { border-color:var(--red); } + .post-active { border-color:var(--red) !important; background:#0d0000 !important; + border-left-width:8px !important; box-shadow:none !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; } + .post-time { color:#aaa; font-family:'Courier New',monospace; font-size:0.82rem; } + .post-text { font-family:'Arial',sans-serif; font-size:1rem; line-height:1.5; } + .post-text a { color:var(--red); text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:3px solid #fff; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(0,0,0,0.98); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:780px; margin:0 auto; background:#000; + border:4px solid #fff; border-radius:0; padding:38px; + box-shadow:8px 8px 0 var(--red); } + .modal-close { float:right; background:none; border:none; color:var(--red); + font-family:Impact; font-size:1.3rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:10px 16px;} .logo-mark{font-size:2rem;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>SNONUX.FOO</h1> + <p class="subtitle">MICROBLOG — <a href="https://foo.zone">FOO.ZONE</a> IS THE REAL BLOG</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">TRANSMIT</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@SNONUX</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo · glass</title> + <style> + :root { --blue:#6366f1; --purple:#a855f7; --pink:#ec4899; --text:#1e1b4b; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; height:100vh; + background:#f0f4ff; color:var(--text); } + /* Blurred gradient blobs that sit behind all glass panels */ + .bg-blobs { position:fixed; inset:0; z-index:0; overflow:hidden; } + .bg-blobs::before { content:''; position:absolute; top:-20%; left:-10%; width:60%; height:70%; + border-radius:50%; background:radial-gradient(circle,rgba(99,102,241,0.35),rgba(168,85,247,0.2),transparent 70%); + filter:blur(60px); } + .bg-blobs::after { content:''; position:absolute; bottom:-10%; right:-10%; width:65%; height:65%; + border-radius:50%; background:radial-gradient(circle,rgba(236,72,153,0.28),rgba(99,102,241,0.18),transparent 70%); + filter:blur(70px); } + .blob3 { position:fixed; top:40%; left:30%; width:40%; height:50%; z-index:0; + border-radius:60% 40% 70% 30%; background:radial-gradient(circle,rgba(168,85,247,0.18),transparent 65%); + filter:blur(50px); } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(255,255,255,0.55); backdrop-filter:blur(20px); + border-bottom:1px solid rgba(255,255,255,0.6); display:flex; align-items:center; justify-content:space-between; + box-shadow:0 2px 12px rgba(99,102,241,0.08); } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2rem; font-weight:800; + background:linear-gradient(135deg,var(--blue),var(--purple)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; } + .logo-title h1 { font-size:1.5rem; font-weight:700; color:var(--text); } + .logo-title .subtitle { font-size:0.75rem; color:#6b7280; margin-top:1px; } + .logo-title .subtitle a { color:var(--blue); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:1px solid rgba(99,102,241,0.4); color:var(--blue); padding:9px 20px; + border-radius:20px; text-decoration:none; font-size:0.85rem; + background:rgba(255,255,255,0.5); backdrop-filter:blur(8px); + transition:all 0.2s; } + .transmit-btn:hover { background:var(--blue); color:#fff; border-color:var(--blue); } + .nav-hints { background:rgba(255,255,255,0.35); backdrop-filter:blur(10px); + border-bottom:1px solid rgba(255,255,255,0.5); color:#6b7280; + padding:4px 28px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(255,255,255,0.7); border:1px solid rgba(99,102,241,0.25); + color:var(--blue); border-radius:4px; padding:0 5px; margin:0 2px; font-size:0.68rem; } + .content { flex:1; overflow-y:auto; padding:20px 28px; + scrollbar-width:thin; scrollbar-color:rgba(99,102,241,0.4) transparent; } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid rgba(99,102,241,0.35); color:var(--blue); padding:8px 20px; + border-radius:20px; text-decoration:none; font-size:0.82rem; + background:rgba(255,255,255,0.45); backdrop-filter:blur(8px); } + .page-nav a:hover { background:var(--blue); color:#fff; } + /* Glass card */ + .post { background:rgba(255,255,255,0.45); backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,0.6); border-radius:14px; + padding:22px; margin-bottom:14px; cursor:pointer; + box-shadow:0 4px 20px rgba(99,102,241,0.08); + transition:all 0.25s; } + .post:hover { background:rgba(255,255,255,0.6); box-shadow:0 8px 30px rgba(99,102,241,0.18); + transform:translateY(-2px); } + .post-active { border-color:var(--blue) !important; + background:rgba(238,240,255,0.75) !important; + box-shadow:0 0 0 2px rgba(99,102,241,0.3),0 8px 30px rgba(99,102,241,0.2), + inset 3px 0 0 var(--blue) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:#9ca3af; font-family:monospace; font-size:0.8rem; } + .post-text { line-height:1.65; font-size:0.95rem; } + .post-text a { color:var(--blue); text-decoration:none; } + .post-text a:hover { text-decoration:underline; } + .post-image { max-width:100%; border-radius:10px; margin-top:10px; + border:1px solid rgba(255,255,255,0.5); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(240,244,255,0.85); backdrop-filter:blur(28px); + overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:rgba(255,255,255,0.7); + backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.75); + border-radius:16px; box-shadow:0 20px 60px rgba(99,102,241,0.18); padding:40px; } + .modal-close { float:right; background:none; border:none; color:#9ca3af; + font-size:0.9rem; cursor:pointer; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} } + </style> +</head> +<body> + <div class="bg-blobs"></div> + <div class="blob3"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo // MATRIX</title> + <style> + :root { --g:#00ff41; --g2:#008f11; --g3:#003b00; --bg:#000; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--g); + overflow:hidden; height:100vh; } + /* scanline overlay */ + body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none; + background:repeating-linear-gradient(0deg,transparent,transparent 3px, + rgba(0,0,0,0.08) 3px,rgba(0,0,0,0.08) 4px); } + @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:12px 24px; background:#000; border-bottom:1px solid var(--g2); + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:1.8rem; color:var(--g); text-shadow:0 0 18px var(--g); letter-spacing:3px; } + /* blinking cursor after logo mark */ + .logo-mark::after { content:'_'; animation:blink 1.2s step-start infinite; } + .logo-title h1 { font-size:1.2rem; color:var(--g); text-shadow:0 0 10px var(--g); + letter-spacing:4px; font-weight:normal; } + .logo-title .subtitle { font-size:0.72rem; color:var(--g2); margin-top:2px; letter-spacing:1px; } + .logo-title .subtitle a { color:var(--g); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 6px var(--g); } + .transmit-btn { border:1px solid var(--g2); color:var(--g); padding:8px 18px; + text-decoration:none; font-size:0.82rem; letter-spacing:2px; + transition:all 0.1s; } + .transmit-btn:hover { background:var(--g); color:var(--bg); } + .nav-hints { background:#000; border-bottom:1px solid var(--g3); color:var(--g2); + padding:4px 24px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:transparent; border:1px solid var(--g3); color:var(--g); + padding:0 5px; font-size:0.68rem; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:14px 24px; + scrollbar-width:thin; scrollbar-color:var(--g2) var(--bg); } + .page-nav { display:flex; justify-content:center; margin:12px 0; } + .page-nav a { border:1px solid var(--g2); color:var(--g); padding:7px 20px; + text-decoration:none; font-size:0.82rem; letter-spacing:2px; } + .page-nav a:hover { background:var(--g); color:var(--bg); } + .post { background:#000; border:1px solid var(--g3); padding:16px 18px; + margin-bottom:10px; cursor:pointer; transition:border-color 0.15s; } + .post:hover { border-color:var(--g2); box-shadow:0 0 8px rgba(0,255,65,0.2); } + .post-active { border-color:var(--g) !important; background:rgba(0,255,65,0.03) !important; + box-shadow:0 0 14px rgba(0,255,65,0.3),inset 3px 0 0 var(--g) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:10px; font-size:0.85rem; } + .post-time { color:var(--g2); font-size:0.78rem; } + .post-text { line-height:1.6; font-size:0.88rem; } + .post-text a { color:var(--g); text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:1px solid var(--g3); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(0,0,0,0.98); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:740px; margin:0 auto; background:#000; + border:1px solid var(--g); padding:36px; + box-shadow:0 0 40px rgba(0,255,65,0.25); } + .modal-close { float:right; background:none; border:none; color:var(--g2); + font-family:monospace; font-size:0.9rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:10px 16px;} .content{padding:10px 16px;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>SNONUX.FOO</h1> + <p class="subtitle">MICROBLOG / <a href="https://foo.zone">FOO.ZONE</a> IS THE REAL BLOG</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">TRANSMIT</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}"><-- NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER --></a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo</title> + <style> + :root { --accent:#0066cc; --border:#e2e2e2; --muted:#666; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; + background:#fff; color:#111; overflow:hidden; height:100vh; } + .overlay { height:100vh; display:flex; flex-direction:column; max-width:860px; + margin:0 auto; } + header { padding:20px 32px; border-bottom:1px solid var(--border); + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:1.5rem; font-weight:800; color:#111; letter-spacing:-1px; } + .logo-title h1 { font-size:1.35rem; font-weight:700; color:#111; letter-spacing:-0.5px; } + .logo-title .subtitle { font-size:0.78rem; color:var(--muted); margin-top:1px; } + .logo-title .subtitle a { color:var(--accent); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:1px solid var(--accent); color:var(--accent); padding:8px 18px; + border-radius:5px; text-decoration:none; font-size:0.88rem; + font-weight:500; transition:all 0.15s; } + .transmit-btn:hover { background:var(--accent); color:#fff; } + .nav-hints { padding:6px 32px; border-bottom:1px solid var(--border); + display:flex; gap:18px; font-size:0.72rem; color:var(--muted); flex-wrap:wrap; } + .nav-hints kbd { background:#f5f5f5; border:1px solid #ccc; border-radius:3px; + padding:1px 5px; font-size:0.72rem; color:#333; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:0 32px; + scrollbar-width:thin; scrollbar-color:#ccc #fff; } + .page-nav { display:flex; justify-content:center; margin:16px 0; } + .page-nav a { border:1px solid var(--border); color:var(--accent); padding:8px 20px; + border-radius:5px; text-decoration:none; font-size:0.88rem; } + .page-nav a:hover { background:var(--accent); color:#fff; border-color:var(--accent); } + .post { border-bottom:1px solid var(--border); padding:22px 0; cursor:pointer; + transition:background 0.12s; } + .post:hover { background:#f8f8f8; padding-left:8px; } + .post-active { background:#eef5ff !important; border-left:3px solid var(--accent) !important; + padding-left:16px !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:10px; + font-size:0.88rem; } + .post-time { color:var(--muted); font-size:0.82rem; } + .post-text { line-height:1.65; font-size:1rem; } + .post-text a { color:var(--accent); text-decoration:none; } + .post-text a:hover { text-decoration:underline; } + .post-image { max-width:100%; border-radius:6px; margin-top:10px; border:1px solid var(--border); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(255,255,255,0.97); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:#fff; + border:1px solid var(--border); border-radius:6px; + box-shadow:0 4px 24px rgba(0,0,0,0.1); padding:40px; } + .modal-close { float:right; background:none; border:none; color:var(--muted); + font-size:0.9rem; cursor:pointer; } + @media(max-width:640px) { .nav-hints{display:none;} .overlay{max-width:100%;} header{padding:16px 20px;} .content{padding:0 20px;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit to Nexus</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <title>snonux.foo • NEON NEXUS</title> + <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> + <style> + @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap'); + :root { --neon-cyan:#00f5ff; --neon-magenta:#ff00cc; --neon-yellow:#ffe700; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Orbitron',sans-serif; background:#0b001a; color:#e0f8ff; overflow:hidden; height:100vh; } + #three-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:1; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 30px; display:flex; align-items:center; justify-content:space-between; + background:rgba(11,0,26,0.8); backdrop-filter:blur(12px); + border-bottom:2px solid rgba(255,231,0,0.3); } + .logo { display:flex; align-items:center; gap:12px; } + #sn-logo { flex-shrink:0; } + .logo-title h1 { font-size:2rem; font-weight:700; letter-spacing:-3px; text-shadow:0 0 25px var(--neon-cyan); } + .logo-title .subtitle { font-size:0.68rem; opacity:0.6; letter-spacing:1px; margin-top:2px; } + .logo-title .subtitle a { color:var(--neon-cyan); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--neon-cyan); } + .nav { display:flex; gap:16px; align-items:center; } + .transmit-btn { background:transparent; border:3px solid var(--neon-yellow); color:var(--neon-yellow); + padding:12px 28px; border-radius:9999px; font-weight:600; letter-spacing:1px; + display:flex; align-items:center; gap:10px; box-shadow:0 0 30px var(--neon-yellow); + transition:all 0.3s; text-decoration:none; font-family:'Orbitron',sans-serif; font-size:0.9rem; } + .transmit-btn:hover { background:var(--neon-yellow); color:#0b001a; transform:scale(1.08); } + .content { flex:1; padding:30px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:#ffe700 #1a0033; } + .page-nav { display:flex; justify-content:center; margin:18px 0; } + .page-nav a { background:transparent; border:2px solid var(--neon-cyan); color:var(--neon-cyan); + padding:10px 28px; border-radius:9999px; font-size:0.85rem; letter-spacing:2px; + text-decoration:none; transition:all 0.3s; } + .page-nav a:hover { background:var(--neon-cyan); color:#0b001a; } + .post { background:rgba(20,5,45,0.9); border:2px solid transparent; + border-image:linear-gradient(45deg,var(--neon-cyan),var(--neon-magenta)) 1; + border-radius:24px; padding:28px; margin-bottom:28px; + box-shadow:0 0 35px rgba(0,245,255,0.5); + transition:all 0.4s cubic-bezier(0.23,1,0.32,1); cursor:pointer; } + .post:hover { transform:translateY(-8px) rotate(1deg); box-shadow:0 0 50px rgba(255,231,0,0.6); } + .post-active { border-image:none !important; border-color:var(--neon-yellow) !important; + background:rgba(40,20,70,0.97) !important; + box-shadow:0 0 0 2px var(--neon-yellow),0 0 30px rgba(255,231,0,0.7), + 0 0 70px rgba(255,231,0,0.35),inset 4px 0 0 var(--neon-yellow) !important; + transform:translateY(-6px) scale(1.012); } + .post-header { display:flex; justify-content:space-between; margin-bottom:18px; font-size:0.95rem; } + .post-time { font-family:monospace; color:var(--neon-yellow); text-shadow:0 0 12px var(--neon-yellow); } + .post-text { font-size:1.1rem; line-height:1.55; } + .post-text a { color:var(--neon-cyan); text-decoration:none; } + .post-text a:hover { text-shadow:0 0 8px var(--neon-cyan); } + .post-image { max-width:100%; border-radius:12px; margin-top:12px; } + .post-audio { width:100%; margin-top:12px; } + .nav-hints { display:flex; gap:20px; justify-content:center; align-items:center; + padding:6px 20px; background:rgba(11,0,26,0.7); + border-bottom:1px solid rgba(0,245,255,0.15); + font-size:0.68rem; letter-spacing:1.5px; color:rgba(224,248,255,0.5); flex-wrap:wrap; } + .nav-hints kbd { display:inline-block; background:rgba(0,245,255,0.1); + border:1px solid rgba(0,245,255,0.35); border-radius:4px; padding:1px 5px; + color:var(--neon-cyan); font-family:monospace; font-size:0.72rem; margin:0 2px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(11,0,26,0.95); backdrop-filter:blur(16px); + overflow-y:auto; padding:40px; } + .post-modal.active { display:block; } + .modal-inner { max-width:800px; margin:0 auto; background:rgba(20,5,45,0.98); + border:2px solid transparent; + border-image:linear-gradient(45deg,var(--neon-yellow),var(--neon-magenta)) 1; + border-radius:24px; padding:40px; box-shadow:0 0 80px rgba(255,231,0,0.4); } + .modal-close { float:right; background:none; border:none; color:var(--neon-cyan); + font-size:1.4rem; cursor:pointer; font-family:'Orbitron',sans-serif; } + @media(max-width:640px) { + .logo-title h1 { font-size:1.6rem; } #sn-logo { width:44px; height:44px; } + .post { padding:22px; margin-bottom:22px; } .content { padding:20px; } + header { padding:14px 20px; } .transmit-btn { padding:9px 16px; font-size:0.8rem; } + .nav-hints { display:none; } .modal-inner { padding:24px 16px; } + } + </style> +</head> +<body> + <canvas id="three-canvas"></canvas> + <div class="overlay"> + <header> + <div class="logo"> + <svg id="sn-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56" width="56" height="56" aria-label="snonux logo"> + <defs> + <linearGradient id="sn-grad" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0%" stop-color="#ffe700"/><stop offset="100%" stop-color="#ff00cc"/> + </linearGradient> + <radialGradient id="sn-bg" cx="40%" cy="35%" r="70%"> + <stop offset="0%" stop-color="#2d0060"/><stop offset="100%" stop-color="#0b001a"/> + </radialGradient> + <filter id="sn-gc" x="-60%" y="-60%" width="220%" height="220%"> + <feGaussianBlur stdDeviation="2.5" result="b"/> + <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge> + </filter> + <filter id="sn-gm" x="-60%" y="-60%" width="220%" height="220%"> + <feGaussianBlur stdDeviation="2.5" result="b"/> + <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge> + </filter> + <filter id="sn-gh" x="-20%" y="-20%" width="140%" height="140%"> + <feGaussianBlur stdDeviation="3" result="b"/> + <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge> + </filter> + </defs> + <polygon points="55,28 41.5,51.4 14.5,51.4 1,28 14.5,4.6 41.5,4.6" + fill="none" stroke="#ffe700" stroke-width="5" opacity="0.18" filter="url(#sn-gh)"/> + <polygon points="55,28 41.5,51.4 14.5,51.4 1,28 14.5,4.6 41.5,4.6" + fill="url(#sn-bg)" stroke="url(#sn-grad)" stroke-width="1.8"/> + <line x1="34" y1="12" x2="22" y2="44" stroke="#ffe700" stroke-width="0.9" opacity="0.75"/> + <rect x="32.5" y="10.5" width="3" height="3" transform="rotate(45 34 12)" fill="#ffe700" opacity="0.8"/> + <rect x="20.5" y="42.5" width="3" height="3" transform="rotate(45 22 44)" fill="#ffe700" opacity="0.8"/> + <text x="9" y="37" font-family="Orbitron,monospace" font-weight="700" font-size="20" + fill="#00f5ff" filter="url(#sn-gc)">S</text> + <text x="28" y="37" font-family="Orbitron,monospace" font-weight="700" font-size="20" + fill="#ff00cc" filter="url(#sn-gm)">N</text> + </svg> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn"> + <i class="fa-solid fa-feather-pointed"></i> TRANSMIT TO NEXUS + </a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}} + <div class="page-nav"><a href="{{.PrevPage}}">← NEWER TRANSMISSIONS</a></div> + {{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}} + <div class="page-nav"><a href="{{.NextPage}}">OLDER TRANSMISSIONS →</a></div> + {{end}} + </div> + </div> + {{template "navmodal" .}} + <script> + // Three.js neon nexus scene — central orb, orbiting rings, particle field. + let scene, camera, renderer, centralSphere, rings = [], particles; + function initThree() { + const canvas = document.getElementById('three-canvas'); + renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0x0b001a, 15, 80); + camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200); + camera.position.set(0, 12, 35); + scene.add(new THREE.AmbientLight(0x00f5ff, 0.8)); + const coreLight = new THREE.PointLight(0xff00cc, 4, 100); + coreLight.position.set(0,0,0); scene.add(coreLight); + centralSphere = new THREE.Mesh(new THREE.SphereGeometry(6,64,64), + new THREE.MeshPhongMaterial({color:0x00f5ff,emissive:0xff00cc,emissiveIntensity:1.8, + shininess:100,transparent:true,opacity:0.95})); + scene.add(centralSphere); + scene.add(new THREE.Mesh(new THREE.SphereGeometry(4.5,64,64), + new THREE.MeshBasicMaterial({color:0x00f5ff,transparent:true,opacity:0.4,blending:THREE.AdditiveBlending}))); + const rc=[0x00f5ff,0xff00cc,0x00f5ff,0xffe700]; + for(let i=0;i<14;i++){ + const ring=new THREE.Mesh(new THREE.TorusGeometry(12+i*2.2,0.35,32,128), + new THREE.MeshPhongMaterial({color:rc[i%4],emissive:rc[i%4],emissiveIntensity:2.5, + shininess:80,transparent:true,opacity:0.9,side:THREE.DoubleSide})); + ring.rotation.x=Math.random()*Math.PI; + ring.userData={speed:0.008+i*0.003,axisTilt:Math.random()*0.6}; + scene.add(ring); rings.push(ring); + } + const pCount=2200,pos=new Float32Array(pCount*3),col=new Float32Array(pCount*3); + for(let i=0;i<pCount*3;i+=3){ + const r=30+Math.random()*40,t=Math.random()*Math.PI*2,p=Math.acos(2*Math.random()-1); + pos[i]=r*Math.sin(p)*Math.cos(t);pos[i+1]=r*Math.sin(p)*Math.sin(t);pos[i+2]=r*Math.cos(p); + const c=new THREE.Color().setHSL(Math.random()>0.5?0.55:0.8,1,1); + col[i]=c.r;col[i+1]=c.g;col[i+2]=c.b; + } + const pg=new THREE.BufferGeometry(); + pg.setAttribute('position',new THREE.BufferAttribute(pos,3)); + pg.setAttribute('color',new THREE.BufferAttribute(col,3)); + particles=new THREE.Points(pg,new THREE.PointsMaterial( + {size:0.22,vertexColors:true,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending})); + scene.add(particles); + let mouseX=0; + window.addEventListener('mousemove',e=>{mouseX=(e.clientX/window.innerWidth)*2-1;}); + (function animate(){ + requestAnimationFrame(animate); + const time=Date.now()*0.0004; + camera.position.x=Math.sin(time)*35+mouseX*6; + camera.position.z=Math.cos(time)*35+10; + camera.lookAt(0,4,0); + centralSphere.rotation.y+=0.003; + rings.forEach((ring,i)=>{ + ring.rotation.y+=ring.userData.speed; + ring.rotation.x=Math.sin(time*1.5+i)*ring.userData.axisTilt; + }); + particles.rotation.y+=0.0008; + renderer.render(scene,camera); + })(); + } + window.addEventListener('resize',()=>{ + if(!camera||!renderer) return; + camera.aspect=window.innerWidth/window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth,window.innerHeight); + }); + window.onload=initThree; + </script> + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo ~ OCEAN</title> + <style> + :root { --teal:#00b4d8; --aqua:#48cae4; --deep:#023e8a; --navy:#03045e; --foam:#caf0f8; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--navy); + color:var(--foam); overflow:hidden; height:100vh; } + /* Deep ocean gradient base */ + body { background:linear-gradient(180deg,#03045e 0%,#023e8a 60%,#0077b6 100%); } + /* Animated wave shimmer at bottom */ + @keyframes wave { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-30px)} } + .wave-bottom { position:fixed; bottom:0; left:-5%; width:110%; height:120px; z-index:1; + background:radial-gradient(ellipse 80% 60% at 50% 100%,rgba(0,180,216,0.22),transparent); + animation:wave 8s ease-in-out infinite; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(3,4,94,0.82); backdrop-filter:blur(12px); + border-bottom:1px solid rgba(0,180,216,0.3); display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2rem; font-weight:800; color:var(--aqua); text-shadow:0 0 16px var(--teal); } + .logo-title h1 { font-size:1.5rem; font-weight:700; color:var(--foam); letter-spacing:1px; } + .logo-title .subtitle { font-size:0.75rem; color:rgba(202,240,248,0.55); margin-top:2px; } + .logo-title .subtitle a { color:var(--aqua); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--teal); } + .transmit-btn { border:1px solid var(--teal); color:var(--teal); padding:9px 20px; + border-radius:20px; text-decoration:none; font-size:0.85rem; + transition:all 0.2s; } + .transmit-btn:hover { background:var(--teal); color:var(--navy); } + .nav-hints { background:rgba(3,4,94,0.65); border-bottom:1px solid rgba(0,180,216,0.18); + color:rgba(202,240,248,0.45); padding:5px 28px; display:flex; gap:18px; + font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(0,180,216,0.12); border:1px solid rgba(0,180,216,0.35); + color:var(--aqua); border-radius:3px; padding:0 5px; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:20px 28px; + scrollbar-width:thin; scrollbar-color:var(--teal) var(--navy); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid var(--deep); color:var(--aqua); padding:8px 20px; + border-radius:20px; text-decoration:none; font-size:0.82rem; } + .page-nav a:hover { background:var(--teal); color:var(--navy); } + .post { background:rgba(3,4,94,0.55); border:1px solid rgba(0,180,216,0.22); border-radius:10px; + padding:20px; margin-bottom:14px; cursor:pointer; + transition:all 0.25s; backdrop-filter:blur(6px); } + .post:hover { border-color:var(--teal); box-shadow:0 4px 24px rgba(0,180,216,0.22); transform:translateY(-2px); } + .post-active { border-color:var(--aqua) !important; background:rgba(0,100,150,0.55) !important; + box-shadow:0 0 22px rgba(72,202,228,0.35),inset 3px 0 0 var(--aqua) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:var(--teal); font-family:monospace; font-size:0.8rem; } + .post-text { line-height:1.65; font-size:0.95rem; } + .post-text a { color:var(--aqua); text-decoration:none; } + .post-text a:hover { text-shadow:0 0 8px var(--teal); } + .post-image { max-width:100%; border-radius:8px; margin-top:10px; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(3,4,94,0.96); backdrop-filter:blur(20px); + overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:rgba(2,30,80,0.98); + border:1px solid var(--teal); border-radius:12px; + box-shadow:0 0 60px rgba(0,180,216,0.3); padding:40px; } + .modal-close { float:right; background:none; border:none; color:var(--teal); + font-size:0.9rem; cursor:pointer; letter-spacing:1px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} } + </style> +</head> +<body> + <div class="wave-bottom"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo — the microblog</title> + <style> + :root { --ink:#2c1810; --sepia:#c8a96e; --paper:#f5f0e8; --muted:#8b6f47; --accent:#7b2d00; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:Georgia,'Times New Roman',serif; background:var(--paper); color:var(--ink); + overflow:hidden; height:100vh; } + /* subtle paper grain simulation */ + body::after { content:''; position:fixed; inset:0; pointer-events:none; z-index:999; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"); + opacity:0.4; } + .overlay { height:100vh; display:flex; flex-direction:column; max-width:800px; margin:0 auto; } + header { padding:18px 28px 14px; border-bottom:3px double var(--ink); + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:2.6rem; font-weight:700; color:var(--accent); line-height:1; font-style:italic; } + .logo-title h1 { font-size:1.6rem; font-weight:700; color:var(--ink); letter-spacing:1px; font-variant:small-caps; } + .logo-title .subtitle { font-size:0.78rem; color:var(--muted); margin-top:2px; font-style:italic; } + .logo-title .subtitle a { color:var(--accent); text-decoration:none; } + .logo-title .subtitle a:hover { text-decoration:underline; } + .transmit-btn { border:2px solid var(--ink); color:var(--ink); padding:8px 16px; + text-decoration:none; font-size:0.82rem; font-variant:small-caps; letter-spacing:1px; + transition:all 0.15s; } + .transmit-btn:hover { background:var(--ink); color:var(--paper); } + .nav-hints { background:transparent; border-bottom:1px solid var(--sepia); color:var(--muted); + padding:4px 28px; display:flex; gap:18px; font-size:0.68rem; font-family:monospace; flex-wrap:wrap; } + .nav-hints kbd { background:transparent; border:1px solid var(--muted); color:var(--ink); + padding:0 4px; font-size:0.68rem; margin:0 1px; } + .content { flex:1; overflow-y:auto; padding:16px 28px; + scrollbar-width:thin; scrollbar-color:var(--sepia) var(--paper); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid var(--ink); color:var(--ink); padding:7px 20px; + text-decoration:none; font-size:0.82rem; font-variant:small-caps; letter-spacing:1px; } + .page-nav a:hover { background:var(--ink); color:var(--paper); } + .post { border-bottom:1px solid var(--sepia); padding:18px 0; cursor:pointer; + transition:background 0.12s; } + .post:hover { background:#ede8dc; padding-left:8px; } + .post-active { background:#e8e0cc !important; border-left:4px solid var(--accent) !important; + padding-left:12px !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:10px; font-size:0.85rem; } + .post-time { color:var(--muted); font-family:monospace; font-size:0.78rem; } + .post-text { line-height:1.7; font-size:1rem; } + .post-text a { color:var(--accent); text-decoration:none; } + .post-text a:hover { text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:1px solid var(--sepia); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(245,240,232,0.97); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:720px; margin:0 auto; background:var(--paper); + border:2px solid var(--ink); padding:40px; + box-shadow:4px 4px 0 var(--sepia); } + .modal-close { float:right; background:none; border:none; color:var(--muted); + font-size:0.9rem; cursor:pointer; font-variant:small-caps; letter-spacing:1px; } + @media(max-width:640px) { .nav-hints{display:none;} .overlay{max-width:100%;} header{padding:14px 16px;} .content{padding:12px 16px;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">Transmit</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← Newer</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">Older →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>SNONUX.FOO // RETRO</title> + <style> + :root { --amber:#ffb000; --dim:#7a5200; --bg:#0a0800; --bg2:#050300; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--amber); + overflow:hidden; height:100vh; } + /* Phosphor scanlines */ + body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none; + background:repeating-linear-gradient(0deg,transparent,transparent 2px, + rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px); } + /* Subtle glow flicker */ + @keyframes amber-flicker { 0%,100%{opacity:1} 94%{opacity:0.98} 96%{opacity:0.93} } + body { animation:amber-flicker 11s infinite; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:12px 24px; background:var(--bg2); border-bottom:2px solid var(--amber); + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:1.6rem; color:var(--amber); text-shadow:0 0 14px var(--amber); + letter-spacing:2px; } + .logo-title h1 { font-size:1.2rem; color:var(--amber); text-shadow:0 0 10px var(--amber); + letter-spacing:4px; font-weight:normal; } + .logo-title .subtitle { font-size:0.72rem; color:var(--dim); margin-top:2px; } + .logo-title .subtitle a { color:var(--amber); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 6px var(--amber); } + .transmit-btn { border:1px solid var(--amber); color:var(--amber); padding:8px 18px; + text-decoration:none; font-size:0.82rem; letter-spacing:2px; + transition:all 0.1s; } + .transmit-btn:hover { background:var(--amber); color:var(--bg); } + .nav-hints { background:var(--bg2); border-bottom:1px solid var(--dim); color:var(--dim); + padding:4px 24px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:transparent; border:1px solid var(--dim); color:var(--amber); + padding:0 5px; font-size:0.68rem; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:14px 24px; + scrollbar-width:thin; scrollbar-color:var(--dim) var(--bg); } + .page-nav { display:flex; justify-content:center; margin:12px 0; } + .page-nav a { border:1px solid var(--dim); color:var(--amber); padding:7px 20px; + text-decoration:none; font-size:0.82rem; letter-spacing:2px; } + .page-nav a:hover { background:var(--amber); color:var(--bg); border-color:var(--amber); } + .post { background:var(--bg); border:1px solid var(--dim); padding:16px 18px; + margin-bottom:10px; cursor:pointer; transition:border-color 0.15s; } + .post:hover { border-color:var(--amber); box-shadow:0 0 8px rgba(255,176,0,0.25); } + .post-active { border-color:var(--amber) !important; + background:rgba(255,176,0,0.04) !important; + box-shadow:0 0 14px rgba(255,176,0,0.3),inset 3px 0 0 var(--amber) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:10px; font-size:0.85rem; } + .post-time { color:var(--dim); font-size:0.78rem; } + .post-text { line-height:1.6; font-size:0.88rem; } + .post-text a { color:var(--amber); text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:1px solid var(--dim); + filter:sepia(60%) hue-rotate(-10deg); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(0,0,0,0.97); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:740px; margin:0 auto; background:var(--bg); + border:1px solid var(--amber); padding:36px; + box-shadow:0 0 40px rgba(255,176,0,0.2); } + .modal-close { float:right; background:none; border:none; color:var(--dim); + font-family:monospace; font-size:0.9rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:10px 16px;} .content{padding:10px 16px;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">[SN]</span> + <div class="logo-title"> + <h1>SNONUX.FOO</h1> + <p class="subtitle">MICROBLOG / <a href="https://foo.zone">FOO.ZONE</a> IS THE REAL BLOG</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">TRANSMIT</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}"><-- NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@SNONUX</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER --></a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo ⊕ SYNTHWAVE</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link href="https://fonts.googleapis.com/css2?family=Russo+One&family=Share+Tech+Mono&display=swap" rel="stylesheet"> + <style> + :root { --pink:#ff2d78; --purple:#bf3fff; --orange:#ff6b2b; --bg:#0d0221; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Russo One','Arial Black',sans-serif; background:var(--bg); + color:#fff; overflow:hidden; height:100vh; } + /* Sunset sky gradient */ + .sky { position:fixed; inset:0; z-index:0; + background:linear-gradient(180deg,#0d0221 0%,#1a0533 45%,#4a0080 72%,#8b1070 88%,#c8365a 100%); } + /* Perspective grid floor */ + .grid-floor { position:fixed; bottom:0; left:0; width:100%; height:46vh; z-index:1; + background-image:linear-gradient(rgba(255,45,120,0.35) 1px,transparent 1px), + linear-gradient(90deg,rgba(255,45,120,0.35) 1px,transparent 1px); + background-size:44px 44px; + transform:perspective(380px) rotateX(76deg); transform-origin:bottom; + mask-image:linear-gradient(to top,rgba(0,0,0,0.85) 0%,transparent 100%); } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:16px 28px; background:rgba(13,2,33,0.82); backdrop-filter:blur(10px); + border-bottom:2px solid var(--pink); display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:1.8rem; background:linear-gradient(90deg,var(--pink),var(--purple)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; } + .logo-title h1 { font-size:1.7rem; background:linear-gradient(90deg,var(--pink),var(--orange)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; letter-spacing:2px; } + .logo-title .subtitle { font-size:0.7rem; color:rgba(255,255,255,0.55); margin-top:2px; + font-family:'Share Tech Mono',monospace; } + .logo-title .subtitle a { color:var(--pink); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--pink); } + .transmit-btn { border:2px solid var(--orange); color:var(--orange); padding:10px 22px; + border-radius:4px; text-decoration:none; letter-spacing:1px; + font-size:0.88rem; transition:all 0.2s; } + .transmit-btn:hover { background:var(--orange); color:var(--bg); } + .nav-hints { background:rgba(13,2,33,0.75); border-bottom:1px solid rgba(255,45,120,0.3); + color:rgba(255,255,255,0.45); padding:5px 20px; display:flex; gap:18px; + font-size:0.68rem; font-family:'Share Tech Mono',monospace; flex-wrap:wrap; } + .nav-hints kbd { background:rgba(255,45,120,0.15); border:1px solid rgba(255,45,120,0.4); + color:var(--pink); border-radius:3px; padding:0 5px; margin:0 2px; font-size:0.7rem; } + .content { flex:1; overflow-y:auto; padding:22px 28px; + scrollbar-width:thin; scrollbar-color:var(--pink) var(--bg); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:2px solid var(--purple); color:var(--purple); padding:8px 22px; + border-radius:4px; text-decoration:none; letter-spacing:2px; font-size:0.82rem; } + .page-nav a:hover { background:var(--purple); color:#fff; } + .post { background:rgba(20,5,50,0.85); border:1px solid var(--purple); border-radius:6px; + padding:22px; margin-bottom:18px; cursor:pointer; transition:all 0.25s; } + .post:hover { border-color:var(--pink); box-shadow:0 0 22px rgba(255,45,120,0.35); transform:translateY(-3px); } + .post-active { border-color:var(--orange) !important; background:rgba(30,8,60,0.96) !important; + box-shadow:0 0 22px rgba(255,107,43,0.45),inset 3px 0 0 var(--orange) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:14px; } + .post-time { color:var(--orange); font-family:'Share Tech Mono',monospace; font-size:0.85rem; } + .post-text { line-height:1.6; font-size:0.95rem; font-family:'Share Tech Mono',monospace; } + .post-text a { color:var(--pink); text-decoration:none; } + .post-image { max-width:100%; border-radius:6px; margin-top:10px; } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(13,2,33,0.96); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:780px; margin:0 auto; background:rgba(20,5,50,0.98); + border:2px solid var(--pink); border-radius:6px; + box-shadow:0 0 60px rgba(255,45,120,0.35); padding:38px; } + .modal-close { float:right; background:none; border:none; color:var(--orange); + font-family:'Russo One',sans-serif; font-size:0.9rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} .grid-floor{height:30vh;} header{padding:12px 18px;} } + </style> +</head> +<body> + <div class="sky"></div> + <div class="grid-floor"></div> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">SN</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog — <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">TRANSMIT TO NEXUS</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">← NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER →</a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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 = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>snonux.foo // TERMINAL</title> + <style> + :root { --p:#33ff33; --dim:#1a7a1a; --bg:#0a0a0a; --bg2:#050505; } + * { margin:0; padding:0; box-sizing:border-box; } + body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--p); + overflow:hidden; height:100vh; position:relative; } + /* CRT scanlines */ + body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none; + background:repeating-linear-gradient(0deg,transparent,transparent 2px, + rgba(0,0,0,0.12) 2px,rgba(0,0,0,0.12) 4px); } + /* Subtle screen flicker */ + @keyframes flicker { 0%,100%{opacity:1} 93%{opacity:0.97} 95%{opacity:0.91} 97%{opacity:0.98} } + body { animation:flicker 9s infinite; } + .overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; } + header { padding:12px 24px; background:var(--bg2); border-bottom:2px solid var(--p); + display:flex; align-items:center; justify-content:space-between; } + .logo { display:flex; align-items:center; gap:14px; } + .logo-mark { font-size:1.6rem; color:var(--p); text-shadow:0 0 14px var(--p); letter-spacing:2px; } + .logo-title h1 { font-size:1.3rem; color:var(--p); text-shadow:0 0 10px var(--p); + letter-spacing:3px; font-weight:normal; } + .logo-title .subtitle { font-size:0.72rem; color:var(--dim); margin-top:2px; } + .logo-title .subtitle a { color:var(--p); text-decoration:none; } + .logo-title .subtitle a:hover { text-shadow:0 0 6px var(--p); } + .nav a.transmit-btn { border:1px solid var(--p); color:var(--p); padding:8px 18px; + border-radius:0; text-decoration:none; letter-spacing:2px; font-size:0.85rem; + transition:all 0.2s; } + .nav a.transmit-btn:hover { background:var(--p); color:var(--bg); } + .nav-hints { background:var(--bg2); border-bottom:1px solid var(--dim); color:var(--dim); + padding:5px 24px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; } + .nav-hints kbd { background:transparent; border:1px solid var(--dim); color:var(--p); + border-radius:0; padding:0 5px; font-size:0.7rem; margin:0 2px; } + .content { flex:1; overflow-y:auto; padding:16px 24px; + scrollbar-width:thin; scrollbar-color:var(--dim) var(--bg); } + .page-nav { display:flex; justify-content:center; margin:14px 0; } + .page-nav a { border:1px solid var(--dim); color:var(--p); padding:7px 20px; + border-radius:0; text-decoration:none; letter-spacing:2px; font-size:0.82rem; } + .page-nav a:hover { background:var(--p); color:var(--bg); border-color:var(--p); } + .post { background:var(--bg); border:1px solid var(--dim); border-radius:0; + padding:18px 20px; margin-bottom:12px; cursor:pointer; transition:border-color 0.15s; } + .post:hover { border-color:var(--p); box-shadow:0 0 8px rgba(51,255,51,0.3); } + .post-active { border-color:var(--p) !important; background:rgba(51,255,51,0.04) !important; + box-shadow:0 0 14px rgba(51,255,51,0.3),inset 3px 0 0 var(--p) !important; } + .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; } + .post-time { color:var(--dim); font-size:0.82rem; } + .post-text { line-height:1.6; font-size:0.92rem; } + .post-text a { color:var(--p); text-decoration:underline; } + .post-image { max-width:100%; margin-top:10px; border:1px solid var(--dim); } + .post-audio { width:100%; margin-top:10px; } + .post-modal { display:none; position:fixed; inset:0; z-index:100; + background:rgba(0,0,0,0.97); overflow-y:auto; padding:40px 20px; } + .post-modal.active { display:block; } + .modal-inner { max-width:760px; margin:0 auto; background:var(--bg); + border:1px solid var(--p); border-radius:0; + box-shadow:0 0 40px rgba(51,255,51,0.25); padding:36px; } + .modal-close { float:right; background:none; border:none; color:var(--p); + font-family:monospace; font-size:0.9rem; cursor:pointer; letter-spacing:2px; } + @media(max-width:640px) { .nav-hints{display:none;} header{padding:10px 16px;} .content{padding:12px 16px;} } + </style> +</head> +<body> + <div class="overlay"> + <header> + <div class="logo"> + <span class="logo-mark">[SN]</span> + <div class="logo-title"> + <h1>snonux.foo</h1> + <p class="subtitle">microblog / <a href="https://foo.zone">foo.zone</a> is the real blog</p> + </div> + </div> + <div class="nav"> + <a href="https://foo.zone/about" class="transmit-btn">> TRANSMIT</a> + </div> + </header> + {{template "navhints" .}} + <div class="content" id="post-content"> + {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}"><-- NEWER</a></div>{{end}} + {{range $i, $post := .Posts}} + <div class="post" data-index="{{$i}}" onclick="selectPost({{$i}})"> + <div class="post-header"> + <div><strong>@snonux</strong></div> + <div class="post-time">{{$post.FormattedTime}}</div> + </div> + <div class="post-text">{{$post.ContentHTML}}</div> + </div> + {{end}} + {{if .NextPage}}<div class="page-nav"><a href="{{.NextPage}}">OLDER --></a></div>{{end}} + </div> + </div> + {{template "navmodal" .}} + {{template "navscript" .}} +</body> +</html>` 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/<ID>/. +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 <audio> snippet. +// The audio element has controls enabled so visitors can play it inline. +func processAudio(srcPath, destDir, postID string) (filename, htmlContent string, err error) { + outName := filepath.Base(srcPath) + outPath := filepath.Join(destDir, outName) + + if err := copyFile(srcPath, outPath); err != nil { + return "", "", err + } + + // The src attribute is relative to the site root. + src := fmt.Sprintf("posts/%s/%s", postID, outName) + html := fmt.Sprintf( + `<audio controls class="post-audio"><source src="%s" type="audio/mpeg">Your browser does not support audio.</audio>`, + src, + ) + + return outName, html, nil +} + +// copyFile copies the file at src to dst, creating dst if it does not exist. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source %s: %w", src, err) + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create dest %s: %w", dst, err) + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy %s → %s: %w", src, dst, err) + } + + return nil +} diff --git a/internal/processor/image.go b/internal/processor/image.go new file mode 100644 index 0000000..9a7d769 --- /dev/null +++ b/internal/processor/image.go @@ -0,0 +1,116 @@ +package processor + +import ( + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "os" + "path/filepath" + + "golang.org/x/image/draw" +) + +const ( + maxImageWidth = 1024 + jpegQuality = 80 +) + +// processImage reads the source image, resizes it if wider than maxImageWidth, +// encodes it as JPEG at jpegQuality, and writes the result to destDir. +// Returns the output filename (always a .jpg) and an HTML <img> snippet. +func processImage(srcPath, destDir, postID string) (filename, htmlContent string, err error) { + img, err := decodeImage(srcPath) + if err != nil { + return "", "", err + } + + img = resizeIfNeeded(img) + + outName := "image.jpg" + outPath := filepath.Join(destDir, outName) + + if err := writeJPEG(img, outPath); err != nil { + return "", "", err + } + + // The <img> src is relative to the site root, pointing into the posts dir. + src := fmt.Sprintf("posts/%s/%s", postID, outName) + html := fmt.Sprintf(`<img src="%s" alt="" class="post-image">`, src) + + return outName, html, nil +} + +// decodeImage decodes a JPEG, PNG, or GIF (first frame) from srcPath. +func decodeImage(srcPath string) (image.Image, error) { + f, err := os.Open(srcPath) + if err != nil { + return nil, fmt.Errorf("open image %s: %w", srcPath, err) + } + defer f.Close() + + ext := filepath.Ext(srcPath) + switch ext { + case ".jpg", ".jpeg": + img, err := jpeg.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode JPEG %s: %w", srcPath, err) + } + return img, nil + + case ".png": + img, err := png.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode PNG %s: %w", srcPath, err) + } + return img, nil + + case ".gif": + // Use only the first frame of animated GIFs. + g, err := gif.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode GIF %s: %w", srcPath, err) + } + return g, nil + + default: + return nil, fmt.Errorf("unsupported image format: %s", ext) + } +} + +// resizeIfNeeded returns a resized copy of img if its width exceeds maxImageWidth, +// preserving aspect ratio. Otherwise the original is returned unchanged. +func resizeIfNeeded(img image.Image) image.Image { + bounds := img.Bounds() + w := bounds.Dx() + + if w <= maxImageWidth { + return img + } + + h := bounds.Dy() + newW := maxImageWidth + newH := (h * newW) / w + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.BiLinear.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) + + return dst +} + +// writeJPEG encodes img as JPEG at the configured quality level and writes to path. +func writeJPEG(img image.Image, path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create JPEG %s: %w", path, err) + } + defer f.Close() + + opts := &jpeg.Options{Quality: jpegQuality} + if err := jpeg.Encode(f, img, opts); err != nil { + return fmt.Errorf("encode JPEG %s: %w", path, err) + } + + return nil +} diff --git a/internal/processor/markdown.go b/internal/processor/markdown.go new file mode 100644 index 0000000..8d69bfe --- /dev/null +++ b/internal/processor/markdown.go @@ -0,0 +1,68 @@ +package processor + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" +) + +// imageRefPattern matches Markdown image syntax:  +// We use it to discover local asset references that must be copied. +var imageRefPattern = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) + +// processMd converts a Markdown file to an HTML snippet. +// Returns the HTML and a list of local image filenames referenced in the document. +// Referenced images that exist alongside the source file are returned so the +// caller can copy them into the post asset directory. +func processMd(path string) (htmlContent string, localImages []string, err error) { + data, err := os.ReadFile(path) + if err != nil { + return "", nil, fmt.Errorf("read markdown %s: %w", path, err) + } + + // Collect local image references so the caller can copy them as assets. + localImages = findLocalImages(string(data), filepath.Dir(path)) + + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions( + html.WithUnsafe(), // Allow raw HTML in markdown (user-controlled content). + ), + ) + + var buf bytes.Buffer + if err := md.Convert(data, &buf); err != nil { + return "", nil, fmt.Errorf("convert markdown %s: %w", path, err) + } + + return buf.String(), localImages, nil +} + +// findLocalImages returns image filenames referenced in markdown that actually +// exist in sourceDir. Remote URLs (http/https) are ignored. +func findLocalImages(mdContent, sourceDir string) []string { + matches := imageRefPattern.FindAllStringSubmatch(mdContent, -1) + var locals []string + + for _, m := range matches { + ref := m[1] + // Skip remote URLs. + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + continue + } + + candidate := filepath.Join(sourceDir, ref) + if _, err := os.Stat(candidate); err == nil { + locals = append(locals, filepath.Base(ref)) + } + } + + return locals +} diff --git a/internal/processor/processor.go b/internal/processor/processor.go new file mode 100644 index 0000000..077cc5b --- /dev/null +++ b/internal/processor/processor.go @@ -0,0 +1,234 @@ +// Package processor scans the input directory for new source files and converts +// each one into a self-contained post directory under outdir/posts/. +// Supported formats: .txt, .md, .png, .jpg, .jpeg, .gif, .mp3. +// Each processed source file is deleted from the input directory afterward. +package processor + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +// Run scans cfg.InputDir and processes every eligible file into a post directory +// under cfg.OutputDir/posts/. Returns the number of posts created. +// +// Images referenced by a .md file in the same input directory are consumed by +// that markdown post and are not processed as independent image posts. +func Run(cfg *config.Config) (int, error) { + entries, err := os.ReadDir(cfg.InputDir) + if err != nil { + return 0, fmt.Errorf("read input dir %s: %w", cfg.InputDir, err) + } + + postsDir := filepath.Join(cfg.OutputDir, "posts") + if err := os.MkdirAll(postsDir, 0o755); err != nil { + return 0, fmt.Errorf("create posts dir: %w", err) + } + + // Pre-scan markdown files to discover which image filenames they claim. + // Claimed images are excluded from independent processing. + claimed := claimedByMarkdown(entries, cfg.InputDir) + + count := 0 + + for _, entry := range entries { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + if claimed[entry.Name()] { + continue // consumed by a .md post — skip independent processing + } + + srcPath := filepath.Join(cfg.InputDir, entry.Name()) + if err := processFile(srcPath, postsDir); err != nil { + return count, fmt.Errorf("process %s: %w", entry.Name(), err) + } + + count++ + } + + return count, nil +} + +// claimedByMarkdown scans all .md entries in inputDir and returns a set of +// image filenames that are referenced within those markdown files. +// Those images should be embedded in the markdown post, not processed alone. +func claimedByMarkdown(entries []os.DirEntry, inputDir string) map[string]bool { + claimed := make(map[string]bool) + + for _, entry := range entries { + if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".md" { + continue + } + + mdPath := filepath.Join(inputDir, entry.Name()) + data, err := os.ReadFile(mdPath) + if err != nil { + continue + } + + for _, imgName := range findLocalImages(string(data), inputDir) { + claimed[imgName] = true + } + } + + return claimed +} + +// processFile processes a single input file into a new post directory. +// The source file is removed from the input dir on success. +func processFile(srcPath, postsDir string) error { + now := time.Now().UTC() + id := uniqueID(postsDir, now) + + postDir := filepath.Join(postsDir, id) + if err := os.MkdirAll(postDir, 0o755); err != nil { + return fmt.Errorf("create post dir %s: %w", id, err) + } + + p, err := buildPost(srcPath, postDir, id) + if err != nil { + // Clean up the half-created directory to avoid partial state. + _ = os.RemoveAll(postDir) + return err + } + + if err := p.Save(postDir); err != nil { + _ = os.RemoveAll(postDir) + return err + } + + // Delete the source file only after the post has been successfully persisted. + return os.Remove(srcPath) +} + +// buildPost dispatches to the appropriate sub-processor based on file extension +// and returns a populated Post ready to be saved. +func buildPost(srcPath, postDir, id string) (*post.Post, error) { + ext := strings.ToLower(filepath.Ext(srcPath)) + + switch ext { + case ".txt": + return buildTextPost(srcPath, id) + + case ".md": + return buildMarkdownPost(srcPath, postDir, id) + + case ".png", ".jpg", ".jpeg", ".gif": + return buildImagePost(srcPath, postDir, id) + + case ".mp3": + return buildAudioPost(srcPath, postDir, id) + + default: + return nil, fmt.Errorf("unsupported file type: %s", ext) + } +} + +func buildTextPost(srcPath, id string) (*post.Post, error) { + html, err := processTxt(srcPath) + if err != nil { + return nil, err + } + + return &post.Post{ + ID: id, + Timestamp: time.Now().UTC(), + PostType: post.TypeText, + Content: html, + }, nil +} + +func buildMarkdownPost(srcPath, postDir, id string) (*post.Post, error) { + html, localImages, err := processMd(srcPath) + if err != nil { + return nil, err + } + + sourceDir := filepath.Dir(srcPath) + + assets, err := copyLocalImages(localImages, sourceDir, postDir) + if err != nil { + return nil, err + } + + // Delete the referenced image files from the input dir so they are not + // processed again as independent posts. + for _, name := range localImages { + _ = os.Remove(filepath.Join(sourceDir, name)) + } + + return &post.Post{ + ID: id, + Timestamp: time.Now().UTC(), + PostType: post.TypeMarkdown, + Content: html, + Assets: assets, + }, nil +} + +func buildImagePost(srcPath, postDir, id string) (*post.Post, error) { + filename, html, err := processImage(srcPath, postDir, id) + if err != nil { + return nil, err + } + + return &post.Post{ + ID: id, + Timestamp: time.Now().UTC(), + PostType: post.TypeImage, + Content: html, + Assets: []string{filename}, + }, nil +} + +func buildAudioPost(srcPath, postDir, id string) (*post.Post, error) { + filename, html, err := processAudio(srcPath, postDir, id) + if err != nil { + return nil, err + } + + return &post.Post{ + ID: id, + Timestamp: time.Now().UTC(), + PostType: post.TypeAudio, + Content: html, + Assets: []string{filename}, + }, nil +} + +// copyLocalImages copies referenced image files from sourceDir into postDir. +// Returns the list of filenames that were successfully copied. +func copyLocalImages(filenames []string, sourceDir, postDir string) ([]string, error) { + var copied []string + + for _, name := range filenames { + src := filepath.Join(sourceDir, name) + dst := filepath.Join(postDir, name) + + if err := copyFile(src, dst); err != nil { + return nil, fmt.Errorf("copy image asset %s: %w", name, err) + } + + copied = append(copied, name) + } + + return copied, nil +} + +// uniqueID generates a post ID for the given time that does not already exist +// as a directory under postsDir. Appends a numeric suffix if needed. +func uniqueID(postsDir string, t time.Time) string { + for i := 0; ; i++ { + id := post.NewID(t, i) + if _, err := os.Stat(filepath.Join(postsDir, id)); os.IsNotExist(err) { + return id + } + } +} diff --git a/internal/processor/txt.go b/internal/processor/txt.go new file mode 100644 index 0000000..8381271 --- /dev/null +++ b/internal/processor/txt.go @@ -0,0 +1,103 @@ +package processor + +import ( + "fmt" + "html" + "os" + "regexp" + "strings" +) + +// urlPattern matches http/https URLs in plain text. +// Trailing sentence punctuation is stripped separately by stripURLTrailing. +var urlPattern = regexp.MustCompile(`https?://\S+`) + +// processTxt reads a plain-text file and wraps each non-empty paragraph in <p> tags. +// URLs are automatically converted to clickable <a> links. +// Non-URL text is HTML-escaped to prevent XSS. +func processTxt(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read txt %s: %w", path, err) + } + + raw := strings.TrimSpace(string(data)) + if raw == "" { + return "<p></p>", nil + } + + // Split on blank lines to get logical paragraphs. + paragraphs := strings.Split(raw, "\n\n") + var sb strings.Builder + + for _, para := range paragraphs { + trimmed := strings.TrimSpace(para) + if trimmed == "" { + continue + } + fmt.Fprintf(&sb, "<p>%s</p>\n", formatParagraph(trimmed)) + } + + return sb.String(), nil +} + +// formatParagraph formats a single paragraph: auto-links URLs, escapes non-URL +// text, and converts single newlines to <br> line breaks. +func formatParagraph(para string) string { + lines := strings.Split(para, "\n") + formatted := make([]string, 0, len(lines)) + + for _, line := range lines { + if t := strings.TrimSpace(line); t != "" { + formatted = append(formatted, autolinkLine(t)) + } + } + + return strings.Join(formatted, "<br>\n") +} + +// autolinkLine escapes non-URL text and wraps detected URLs in <a> tags. +// Opens in a new tab with rel="noopener noreferrer" for security. +func autolinkLine(line string) string { + locs := urlPattern.FindAllStringIndex(line, -1) + if len(locs) == 0 { + return html.EscapeString(line) + } + + var sb strings.Builder + prev := 0 + + for _, loc := range locs { + sb.WriteString(html.EscapeString(line[prev:loc[0]])) + + rawURL := line[loc[0]:loc[1]] + cleanURL := stripURLTrailing(rawURL) + trailing := rawURL[len(cleanURL):] + + fmt.Fprintf(&sb, `<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>`, + html.EscapeString(cleanURL), html.EscapeString(cleanURL)) + + if trailing != "" { + sb.WriteString(html.EscapeString(trailing)) + } + + prev = loc[1] + } + + sb.WriteString(html.EscapeString(line[prev:])) + + return sb.String() +} + +// stripURLTrailing removes common sentence-ending punctuation from the end of a +// URL match. These characters are valid in URLs but almost never appear there +// at the end in prose (e.g. "Visit https://foo.com." — the "." ends the sentence). +func stripURLTrailing(u string) string { + const cutset = ".,;:!?\"')>]}" + + for len(u) > 0 && strings.ContainsRune(cutset, rune(u[len(u)-1])) { + u = u[:len(u)-1] + } + + return u +} |
