summaryrefslogtreecommitdiff
path: root/internal/generator
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-25 22:13:32 +0300
committerPaul Buetow <paul@buetow.org>2026-04-25 22:13:32 +0300
commita5cb9f7ad4b2bcb7ac367854147f661ab83c5ec1 (patch)
tree96db490ed72dec49c4bc2aba8539fd57f3246f47 /internal/generator
parentcc9c69ce748416ad1d9b28592ec463434023d63c (diff)
can swap themes dynamicallyv0.12.0
Diffstat (limited to 'internal/generator')
-rw-r--r--internal/generator/doc.go33
-rw-r--r--internal/generator/favicon.go27
-rw-r--r--internal/generator/generator.go198
-rw-r--r--internal/generator/generator_test.go32
-rw-r--r--internal/generator/templates.go11
-rw-r--r--internal/generator/templates/embed.go65
-rw-r--r--internal/generator/templates/shared/favicon_head.tmpl2
-rw-r--r--internal/generator/templates/shared/nav.tmpl1772
-rw-r--r--internal/generator/templates/shared/shared.css925
-rw-r--r--internal/generator/templates/shared/shared.js1343
-rw-r--r--internal/generator/templates/shell.tmpl77
-rw-r--r--internal/generator/templates/themes/aurora.tmpl359
-rw-r--r--internal/generator/templates/themes/aurora/meta.json7
-rw-r--r--internal/generator/templates/themes/aurora/theme.css94
-rw-r--r--internal/generator/templates/themes/aurora/theme.js200
-rw-r--r--internal/generator/templates/themes/biomech.tmpl217
-rw-r--r--internal/generator/templates/themes/biomech/meta.json7
-rw-r--r--internal/generator/templates/themes/biomech/theme.css52
-rw-r--r--internal/generator/templates/themes/biomech/theme.js98
-rw-r--r--internal/generator/templates/themes/brutalist.tmpl260
-rw-r--r--internal/generator/templates/themes/brutalist/meta.json7
-rw-r--r--internal/generator/templates/themes/brutalist/theme.css65
-rw-r--r--internal/generator/templates/themes/brutalist/theme.js132
-rw-r--r--internal/generator/templates/themes/cathedral/meta.json7
-rw-r--r--internal/generator/templates/themes/cathedral/theme.css79
-rw-r--r--internal/generator/templates/themes/cathedral/theme.js (renamed from internal/generator/templates/themes/cathedral.tmpl)154
-rw-r--r--internal/generator/templates/themes/cosmos/meta.json7
-rw-r--r--internal/generator/templates/themes/cosmos/theme.css81
-rw-r--r--internal/generator/templates/themes/cosmos/theme.js (renamed from internal/generator/templates/themes/cosmos.tmpl)152
-rw-r--r--internal/generator/templates/themes/dos.tmpl302
-rw-r--r--internal/generator/templates/themes/dos/meta.json7
-rw-r--r--internal/generator/templates/themes/dos/theme.css79
-rw-r--r--internal/generator/templates/themes/dos/theme.js157
-rw-r--r--internal/generator/templates/themes/matrix/meta.json7
-rw-r--r--internal/generator/templates/themes/matrix/theme.css76
-rw-r--r--internal/generator/templates/themes/matrix/theme.js (renamed from internal/generator/templates/themes/matrix.tmpl)149
-rw-r--r--internal/generator/templates/themes/neon.tmpl348
-rw-r--r--internal/generator/templates/themes/neon/meta.json7
-rw-r--r--internal/generator/templates/themes/neon/theme.css94
-rw-r--r--internal/generator/templates/themes/neon/theme.js155
-rw-r--r--internal/generator/templates/themes/noir/meta.json7
-rw-r--r--internal/generator/templates/themes/noir/theme.css77
-rw-r--r--internal/generator/templates/themes/noir/theme.js (renamed from internal/generator/templates/themes/noir.tmpl)152
-rw-r--r--internal/generator/templates/themes/ocean/meta.json7
-rw-r--r--internal/generator/templates/themes/ocean/theme.css69
-rw-r--r--internal/generator/templates/themes/ocean/theme.js (renamed from internal/generator/templates/themes/ocean.tmpl)139
-rw-r--r--internal/generator/templates/themes/plasma/meta.json7
-rw-r--r--internal/generator/templates/themes/plasma/theme.css78
-rw-r--r--internal/generator/templates/themes/plasma/theme.js (renamed from internal/generator/templates/themes/plasma.tmpl)148
-rw-r--r--internal/generator/templates/themes/retro/meta.json7
-rw-r--r--internal/generator/templates/themes/retro/theme.css79
-rw-r--r--internal/generator/templates/themes/retro/theme.js (renamed from internal/generator/templates/themes/retro.tmpl)148
-rw-r--r--internal/generator/templates/themes/retrofuture.tmpl374
-rw-r--r--internal/generator/templates/themes/retrofuture/meta.json7
-rw-r--r--internal/generator/templates/themes/retrofuture/theme.css105
-rw-r--r--internal/generator/templates/themes/retrofuture/theme.js193
-rw-r--r--internal/generator/templates/themes/spaceage/meta.json7
-rw-r--r--internal/generator/templates/themes/spaceage/theme.css80
-rw-r--r--internal/generator/templates/themes/spaceage/theme.js (renamed from internal/generator/templates/themes/spaceage.tmpl)151
-rw-r--r--internal/generator/templates/themes/surveillance.tmpl224
-rw-r--r--internal/generator/templates/themes/surveillance/meta.json7
-rw-r--r--internal/generator/templates/themes/surveillance/theme.css50
-rw-r--r--internal/generator/templates/themes/surveillance/theme.js107
-rw-r--r--internal/generator/templates/themes/synthwave/meta.json7
-rw-r--r--internal/generator/templates/themes/synthwave/theme.css85
-rw-r--r--internal/generator/templates/themes/synthwave/theme.js (renamed from internal/generator/templates/themes/synthwave.tmpl)158
-rw-r--r--internal/generator/templates/themes/terminal.tmpl275
-rw-r--r--internal/generator/templates/themes/terminal/meta.json7
-rw-r--r--internal/generator/templates/themes/terminal/theme.css70
-rw-r--r--internal/generator/templates/themes/terminal/theme.js141
-rw-r--r--internal/generator/templates/themes/tropicale/meta.json7
-rw-r--r--internal/generator/templates/themes/tropicale/theme.css81
-rw-r--r--internal/generator/templates/themes/tropicale/theme.js (renamed from internal/generator/templates/themes/tropicale.tmpl)152
-rw-r--r--internal/generator/templates/themes/volcano/meta.json7
-rw-r--r--internal/generator/templates/themes/volcano/theme.css69
-rw-r--r--internal/generator/templates/themes/volcano/theme.js (renamed from internal/generator/templates/themes/volcano.tmpl)139
-rw-r--r--internal/generator/themes.go33
77 files changed, 5457 insertions, 5841 deletions
diff --git a/internal/generator/doc.go b/internal/generator/doc.go
index d7d4a53..be82bdc 100644
--- a/internal/generator/doc.go
+++ b/internal/generator/doc.go
@@ -4,21 +4,26 @@
// Responsibilities by area (file → role):
//
// - generator.go — Orchestration: load posts from disk, sort newest-first,
-// paginate, parse theme+nav templates, write index.html / pageN.html,
-// then call atom.Generate.
-// - themes.go — Theme registry (name → template string) and getTheme /
-// ListThemes for the CLI.
-// - theme_*.go — One file per visual theme: full-page HTML that invokes
-// {{template "navSharedCSSInner"}} inside <style>, then {{template "splashGate"}},
-// {{template "navhints" .}}, {{template "navmodal" .}},
-// {{template "navscript" .}}.
-// - shared.go — navDefs: shared {{define}} blocks merged at parse time with
-// the chosen theme so a single html/template parse sees every name.
-// - theme_sounds.go — Per-theme Web Audio parameters (splash arpeggio, nav blip,
-// modal open/close); embedded in pages as ThemeSoundsJSON for navscript.
-// - templates.go — Short pointer: where templates and registry live.
+// paginate, parse shell+nav templates, write index.html / pageN.html plus
+// shared.css/shared.js and per-theme assets, then call atom.Generate.
+// - themes.go — ListThemes / validThemeName helpers backed by the embedded FS.
+// - shared.go — navDefs: shared {{define}} blocks (splashGate, navhints,
+// navmodal) merged at parse time with shell.tmpl so a single html/template
+// parse sees every name.
+// - theme_sounds.go — Per-theme Web Audio parameters; one file is written per
+// theme to dist/themes/<name>/sounds.json and the default theme's preset is
+// also baked into shell.tmpl as window.SNONUX_SOUNDS so the splash chime
+// can fire instantly.
+// - favicon.go — Generates the 32×32 .ico binary written into the output dir.
+// - templates.go — Short pointer: where templates and helpers live.
//
-// Dependency direction: themes and shared nav templates are composed only for
+// Theme assets live as separate files under templates/themes/<name>/theme.css,
+// theme.js, and meta.json. The single shell.tmpl loads the active theme's CSS
+// synchronously in <head> (chosen from localStorage by a boot script) and
+// shared.js orchestrates the rest. Switching themes is a localStorage write
+// followed by location.reload().
+//
+// Dependency direction: shell and shared nav templates are composed only for
// the HTML path (generator.go). Package atom depends on config and post only,
// not on themes or html/template, so feed logic stays isolated from page chrome.
package generator
diff --git a/internal/generator/favicon.go b/internal/generator/favicon.go
index a4a8251..8664183 100644
--- a/internal/generator/favicon.go
+++ b/internal/generator/favicon.go
@@ -6,35 +6,12 @@ import (
"fmt"
"image"
"image/color"
- "log"
"os"
"path/filepath"
- "strings"
-
- "codeberg.org/snonux/snonux/internal/generator/templates"
)
-// faviconHeadHTML is the <link rel="icon"> fragment injected into every theme's
-// <head>. Loaded at startup from the embedded templates FS so no HTML lives in
-// the Go sources.
-var faviconHeadHTML = loadFaviconHeadHTML()
-
-func loadFaviconHeadHTML() string {
- s, err := templates.Shared("favicon_head")
- if err != nil {
- log.Printf("warning: could not load favicon_head template: %v", err)
- return ""
- }
- return s
-}
-
-func injectSharedHead(theme string) string {
- if strings.Contains(theme, `rel="icon"`) {
- return theme
- }
-
- return strings.Replace(theme, "</head>", faviconHeadHTML+"</head>", 1)
-}
+// shell.tmpl now embeds the favicon <link> directly, so no head-injection
+// helper is needed; favicon.go only generates and writes the .ico binary.
func writeFavicon(outputDir string) error {
data, err := generateFaviconICO()
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index dc1f236..079cc38 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -12,17 +12,29 @@ import (
"codeberg.org/snonux/snonux/internal/config"
"codeberg.org/snonux/snonux/internal/generator/atom"
+ "codeberg.org/snonux/snonux/internal/generator/templates"
"codeberg.org/snonux/snonux/internal/post"
)
// pageData holds the template variables for a single HTML page.
+// Theme-specific values come from the default theme's meta.json — they are
+// what the user sees on first paint. shared.js may swap them out at runtime
+// when a non-default theme is saved in localStorage.
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
- ThemeSoundsJSON template.JS // Web Audio preset for this theme (splash + nav)
+ 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
+
+ DefaultTheme string // baked into the shell as SNONUX_DEFAULT_THEME
+ AllThemesJSON template.JS // [...] of all available theme names, JS literal
+ DefaultTitle string // <title> contents for the default theme
+ DefaultHeaderHTML template.HTML // <header> innerHTML for the default theme
+ DefaultSplashHTML template.HTML // #splash-overlay innerHTML for the default theme
+ DefaultPrevText template.HTML // pagination prev anchor text (theme-styled)
+ DefaultNextText template.HTML // pagination next anchor text
+ DefaultSoundsJSON template.JS // default theme's Web Audio preset (literal)
}
// postView is a render-friendly representation of a post for the HTML template.
@@ -32,23 +44,62 @@ type postView struct {
ContentHTML template.HTML // pre-rendered; trusted — generated by this tool
}
-// Run loads all posts, generates all HTML pages, and writes atom.xml.
+// themeMeta mirrors the JSON structure written by the migration tool and used
+// at runtime by shared.js to swap theme markup in.
+type themeMeta struct {
+ Title string `json:"title"`
+ HeaderHTML string `json:"header_html"`
+ SplashInnerHTML string `json:"splash_inner_html"`
+ PrevPageText string `json:"prev_page_text"`
+ NextPageText string `json:"next_page_text"`
+}
+
+func loadThemeMeta(name string) (themeMeta, error) {
+ var m themeMeta
+ b, err := templates.ThemeMeta(name)
+ if err != nil {
+ return m, fmt.Errorf("read theme meta %q: %w", name, err)
+ }
+ if err := json.Unmarshal(b, &m); err != nil {
+ return m, fmt.Errorf("parse theme meta %q: %w", name, err)
+ }
+ return m, nil
+}
+
+// allThemesJSON returns a JS array literal of all theme names.
+func allThemesJSON() (template.JS, error) {
+ names, err := templates.ThemeNames()
+ if err != nil {
+ return "", err
+ }
+ b, err := json.Marshal(names)
+ if err != nil {
+ return "", err
+ }
+ return template.JS(b), nil //nolint:gosec // marshalled from a fixed string slice
+}
+
+// Run loads all posts, generates all HTML pages, and writes atom.xml plus the
+// shared CSS/JS bundles and per-theme asset files.
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 := injectSharedHead(getTheme(cfg.Theme)) + "\n" + navDefs
+ // Combine shell.tmpl with nav.tmpl partials so a single parse call resolves
+ // references to splashGate, navhints, navmodal.
+ shellSrc, err := templates.Shell()
+ if err != nil {
+ return fmt.Errorf("load shell template: %w", err)
+ }
+ combined := shellSrc + "\n" + navDefs
tmpl, err := template.New("page").Parse(combined)
if err != nil {
return fmt.Errorf("parse page template: %w", err)
@@ -57,9 +108,25 @@ func Run(cfg *config.Config) error {
if err := writeFavicon(cfg.OutputDir); err != nil {
return err
}
+ if err := writeSharedAssets(cfg.OutputDir); err != nil {
+ return err
+ }
+ if err := writeAllThemeAssets(cfg.OutputDir); err != nil {
+ return err
+ }
+
+ defaultTheme := validThemeName(cfg.Theme)
+ defaultMeta, err := loadThemeMeta(defaultTheme)
+ if err != nil {
+ return err
+ }
+ all, err := allThemesJSON()
+ if err != nil {
+ return err
+ }
for i, page := range pages {
- if err := writePage(tmpl, page, i, len(pages), cfg); err != nil {
+ if err := writePage(tmpl, page, i, len(pages), cfg, defaultTheme, defaultMeta, all); err != nil {
return err
}
}
@@ -126,8 +193,8 @@ func pageFilename(index int) string {
const indexPageNavURL = "index.html?splash=0"
// 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, cfg.Theme)
+func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config, defaultTheme string, defaultMeta themeMeta, all template.JS) error {
+ data := buildPageData(posts, pageIndex, totalPages, defaultTheme, defaultMeta, all)
filename := pageFilename(pageIndex)
path := filepath.Join(cfg.OutputDir, filename)
@@ -146,7 +213,7 @@ func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPage
}
// buildPageData constructs the template data for a single page.
-func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string) pageData {
+func buildPageData(posts []*post.Post, pageIndex, totalPages int, defaultTheme string, meta themeMeta, all template.JS) pageData {
views := make([]postView, len(posts))
for i, p := range posts {
views[i] = postView{
@@ -173,12 +240,19 @@ func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string)
}
return pageData{
- Posts: views,
- PrevPage: prevPage,
- NextPage: nextPage,
- PrevPageJSON: jsonStringOrNull(prevPage),
- NextPageJSON: jsonStringOrNull(nextPage),
- ThemeSoundsJSON: themeSoundsJSON(theme),
+ Posts: views,
+ PrevPage: prevPage,
+ NextPage: nextPage,
+ PrevPageJSON: jsonStringOrNull(prevPage),
+ NextPageJSON: jsonStringOrNull(nextPage),
+ DefaultTheme: defaultTheme,
+ AllThemesJSON: all,
+ DefaultTitle: meta.Title,
+ DefaultHeaderHTML: template.HTML(meta.HeaderHTML), //nolint:gosec // source is in-tree theme metadata
+ DefaultSplashHTML: template.HTML(meta.SplashInnerHTML), //nolint:gosec // same
+ DefaultPrevText: template.HTML(meta.PrevPageText), //nolint:gosec // same
+ DefaultNextText: template.HTML(meta.NextPageText), //nolint:gosec // same
+ DefaultSoundsJSON: themeSoundsJSON(defaultTheme),
}
}
@@ -202,3 +276,85 @@ func jsonStringOrNull(s string) template.JS {
return template.JS(strings.TrimSpace(string(b))) //nolint:gosec // filename is tool-generated
}
+
+// writeSharedAssets dumps shared.css and shared.js to the output dir. They are
+// linked from every page and cached by browsers across navigations.
+func writeSharedAssets(outputDir string) error {
+ css, err := templates.SharedCSS()
+ if err != nil {
+ return fmt.Errorf("read shared.css: %w", err)
+ }
+ if err := os.WriteFile(filepath.Join(outputDir, "shared.css"), css, 0o644); err != nil {
+ return fmt.Errorf("write shared.css: %w", err)
+ }
+
+ js, err := templates.SharedJS()
+ if err != nil {
+ return fmt.Errorf("read shared.js: %w", err)
+ }
+ if err := os.WriteFile(filepath.Join(outputDir, "shared.js"), js, 0o644); err != nil {
+ return fmt.Errorf("write shared.js: %w", err)
+ }
+
+ return nil
+}
+
+// writeAllThemeAssets copies each theme's CSS, JS, meta.json, and writes its
+// sounds.json to dist/themes/<name>/. shared.js fetches these on theme switch.
+func writeAllThemeAssets(outputDir string) error {
+ names, err := templates.ThemeNames()
+ if err != nil {
+ return err
+ }
+
+ root := filepath.Join(outputDir, "themes")
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return fmt.Errorf("create themes dir: %w", err)
+ }
+
+ for _, name := range names {
+ dir := filepath.Join(root, name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("create theme dir %q: %w", name, err)
+ }
+ if err := writeThemeAsset(dir, name); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// writeThemeAsset copies the four per-theme files into dir.
+func writeThemeAsset(dir, name string) error {
+ css, err := templates.ThemeCSS(name)
+ if err != nil {
+ return fmt.Errorf("read %s/theme.css: %w", name, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "theme.css"), css, 0o644); err != nil {
+ return fmt.Errorf("write %s/theme.css: %w", name, err)
+ }
+
+ js, err := templates.ThemeJS(name)
+ if err != nil {
+ return fmt.Errorf("read %s/theme.js: %w", name, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "theme.js"), js, 0o644); err != nil {
+ return fmt.Errorf("write %s/theme.js: %w", name, err)
+ }
+
+ meta, err := templates.ThemeMeta(name)
+ if err != nil {
+ return fmt.Errorf("read %s/meta.json: %w", name, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "meta.json"), meta, 0o644); err != nil {
+ return fmt.Errorf("write %s/meta.json: %w", name, err)
+ }
+
+ soundsJSON := themeSoundsJSON(name)
+ if err := os.WriteFile(filepath.Join(dir, "sounds.json"), []byte(soundsJSON), 0o644); err != nil {
+ return fmt.Errorf("write %s/sounds.json: %w", name, err)
+ }
+
+ return nil
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7b0009c..d477275 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -179,10 +179,19 @@ func TestBuildPageData_navLinks(t *testing.T) {
},
}
+ meta, err := loadThemeMeta("neon")
+ if err != nil {
+ t.Fatalf("loadThemeMeta: %v", err)
+ }
+ all, err := allThemesJSON()
+ if err != nil {
+ t.Fatalf("allThemesJSON: %v", err)
+ }
+
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- data := buildPageData([]*post.Post{p}, tt.pageIndex, tt.totalPages, "neon")
+ data := buildPageData([]*post.Post{p}, tt.pageIndex, tt.totalPages, "neon", meta, all)
if data.PrevPage != tt.wantPrev {
t.Fatalf("PrevPage=%q; want %q", data.PrevPage, tt.wantPrev)
}
@@ -202,19 +211,24 @@ func TestBuildPageData_navLinks(t *testing.T) {
}
}
-func TestGetTheme_unknownFallsBackToNeon(t *testing.T) {
+func TestValidThemeName_unknownFallsBackToNeon(t *testing.T) {
t.Parallel()
- if got, want := getTheme("no-such-theme-"), getTheme("neon"); got != want {
- t.Fatal("expected neon fallback")
+ if got := validThemeName("no-such-theme-"); got != "neon" {
+ t.Fatalf("validThemeName(\"no-such-theme-\") = %q; want \"neon\"", got)
+ }
+ if got := validThemeName("matrix"); got != "matrix" {
+ t.Fatalf("validThemeName(\"matrix\") = %q; want \"matrix\"", got)
}
}
-func TestInjectSharedHead_addsFaviconLink(t *testing.T) {
+func TestLoadThemeMeta_neonHasFields(t *testing.T) {
t.Parallel()
-
- got := injectSharedHead(getTheme("neon"))
- if !strings.Contains(got, `rel="icon" href="favicon.ico"`) {
- t.Fatalf("favicon link missing from theme head")
+ m, err := loadThemeMeta("neon")
+ if err != nil {
+ t.Fatalf("loadThemeMeta(neon): %v", err)
+ }
+ if m.Title == "" || m.HeaderHTML == "" || m.SplashInnerHTML == "" {
+ t.Fatalf("neon meta missing required fields: %+v", m)
}
}
diff --git a/internal/generator/templates.go b/internal/generator/templates.go
index f50601a..df854ab 100644
--- a/internal/generator/templates.go
+++ b/internal/generator/templates.go
@@ -1,9 +1,10 @@
package generator
-// HTML theme templates and shared sub-templates live as separate files under
-// internal/generator/templates/{themes,shared}/*.tmpl and are loaded into the
+// The unified shell template, shared CSS/JS bundles, and per-theme asset
+// triples live under internal/generator/templates/ and are loaded into the
// binary via embed.FS; see internal/generator/templates/embed.go.
//
-/