summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
committerPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
commit3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch)
tree7d0ac51cfb41b4774db6292deeb0cc3dce93cf07 /internal
parent51f95f88ca78471a50b3fc62dbcea8edb609dc80 (diff)
add snonux static microblog generator
Full Go implementation with: - txt/md/image/audio input processing, URL auto-linking in .txt files - Paginated HTML output with Atom feed - 11 visual themes: neon, terminal, synthwave, minimal, brutalist, paper, aurora, matrix, ocean, retro, glass (selectable via --theme flag) - Keyboard navigation (j/k/arrows, Enter modal, h/l page nav) - Shared nav templates (navhints, navmodal, navscript) across all themes - Magefile build automation; integration test suite covering all themes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go26
-rw-r--r--internal/generator/atom.go104
-rw-r--r--internal/generator/generator.go188
-rw-r--r--internal/generator/shared.go100
-rw-r--r--internal/generator/templates.go5
-rw-r--r--internal/generator/theme_aurora.go114
-rw-r--r--internal/generator/theme_brutalist.go97
-rw-r--r--internal/generator/theme_glass.go123
-rw-r--r--internal/generator/theme_matrix.go102
-rw-r--r--internal/generator/theme_minimal.go96
-rw-r--r--internal/generator/theme_neon.go224
-rw-r--r--internal/generator/theme_ocean.go105
-rw-r--r--internal/generator/theme_paper.go98
-rw-r--r--internal/generator/theme_retro.go105
-rw-r--r--internal/generator/theme_synthwave.go111
-rw-r--r--internal/generator/theme_terminal.go101
-rw-r--r--internal/generator/themes.go44
-rw-r--r--internal/post/post.go84
-rw-r--r--internal/processor/audio.go49
-rw-r--r--internal/processor/image.go116
-rw-r--r--internal/processor/markdown.go68
-rw-r--r--internal/processor/processor.go234
-rw-r--r--internal/processor/txt.go103
23 files changed, 2397 insertions, 0 deletions
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 &mdash; <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}}">&larr; 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 &rarr;</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 &mdash; <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}}">&larr; 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 &rarr;</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; }
+ .cont