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; }
+ .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 &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_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}}">&lt;-- 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 --&gt;</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 &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 to Nexus</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_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 &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">
+ <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}}">&larr; 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 &rarr;</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 &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_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 &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_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}}">&lt;-- 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 --&gt;</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 &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 TO NEXUS</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_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">&gt; TRANSMIT</a>
+ </div>
+ </header>
+ {{template "navhints" .}}
+ <div class="content" id="post-content">
+ {{if .PrevPage}}<div class="page-nav"><a href="{{.PrevPage}}">&lt;-- 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 --&gt;</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: ![alt](filename)
+// 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
+}