diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
| commit | 1b24076788868f50812468b1bf4bb0ae86214e8c (patch) | |
| tree | 389fd715b5c27e0fe036f6c123b8d09e3872da1c | |
| parent | fbac3d13a575a410c2ee3f49418bf4b462544b4f (diff) | |
Add theme background musicv0.16.0
89 files changed, 712 insertions, 111 deletions
diff --git a/cmd/snonux/sync_test.go b/cmd/snonux/sync_test.go index 93c9233..b8e8701 100644 --- a/cmd/snonux/sync_test.go +++ b/cmd/snonux/sync_test.go @@ -31,7 +31,8 @@ func TestSplitAndTrim(t *testing.T) { } func TestResolveSyncConfig_defaults(t *testing.T) { - t.Parallel() + unsetEnv(t, "SNONUX_SYNC_TARGETS") + unsetEnv(t, "SNONUX_SYNC_REMOTE_DIR") cfg := &config.Config{} resolveSyncConfig(cfg) @@ -45,12 +46,8 @@ func TestResolveSyncConfig_defaults(t *testing.T) { } func TestResolveSyncConfig_envTargets(t *testing.T) { - t.Parallel() - - orig := os.Getenv("SNONUX_SYNC_TARGETS") - defer os.Setenv("SNONUX_SYNC_TARGETS", orig) - - os.Setenv("SNONUX_SYNC_TARGETS", "h1, h2 ,h3") + t.Setenv("SNONUX_SYNC_TARGETS", "h1, h2 ,h3") + unsetEnv(t, "SNONUX_SYNC_REMOTE_DIR") cfg := &config.Config{} resolveSyncConfig(cfg) want := []string{"h1", "h2", "h3"} @@ -60,12 +57,8 @@ func TestResolveSyncConfig_envTargets(t *testing.T) { } func TestResolveSyncConfig_envRemoteDir(t *testing.T) { - t.Parallel() - - orig := os.Getenv("SNONUX_SYNC_REMOTE_DIR") - defer os.Setenv("SNONUX_SYNC_REMOTE_DIR", orig) - - os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/custom/path/") + unsetEnv(t, "SNONUX_SYNC_TARGETS") + t.Setenv("SNONUX_SYNC_REMOTE_DIR", "/custom/path/") cfg := &config.Config{} resolveSyncConfig(cfg) if cfg.SyncRemoteDir != "/custom/path/" { @@ -74,17 +67,8 @@ func TestResolveSyncConfig_envRemoteDir(t *testing.T) { } func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) { - t.Parallel() - - origTargets := os.Getenv("SNONUX_SYNC_TARGETS") - origDir := os.Getenv("SNONUX_SYNC_REMOTE_DIR") - defer func() { - os.Setenv("SNONUX_SYNC_TARGETS", origTargets) - os.Setenv("SNONUX_SYNC_REMOTE_DIR", origDir) - }() - - os.Setenv("SNONUX_SYNC_TARGETS", "from-env") - os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/env/dir/") + t.Setenv("SNONUX_SYNC_TARGETS", "from-env") + t.Setenv("SNONUX_SYNC_REMOTE_DIR", "/env/dir/") cfg := &config.Config{ SyncTargets: []string{"from-flag"}, @@ -99,3 +83,18 @@ func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) { t.Fatalf("flag dir should override env: got %q", cfg.SyncRemoteDir) } } + +func unsetEnv(t *testing.T, key string) { + t.Helper() + orig, hadOrig := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + t.Cleanup(func() { + if hadOrig { + _ = os.Setenv(key, orig) + } else { + _ = os.Unsetenv(key) + } + }) +} diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go index 762bcb8..6a4d437 100644 --- a/integrationtests/integration_test.go +++ b/integrationtests/integration_test.go @@ -24,6 +24,7 @@ import ( "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/generator" "codeberg.org/snonux/snonux/internal/processor" + "codeberg.org/snonux/snonux/internal/version" ) var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file @@ -391,8 +392,8 @@ func TestKeyboardNavJS(t *testing.T) { assertContains(t, sharedJS, "triggerFlashEffect();", "shared.js f triggers flash") // Nav hints and splash hints should display the updated keys. - assertContains(t, index, "<kbd>p</kbd> music", "index.html nav hint p=ambient") - assertContains(t, index, "<kbd>f</kbd> flash", "index.html nav hint f=flash") + assertContains(t, index, "<kbd>p</kbd><span class=\"sno-btn-text\">music</span>", "index.html nav hint p=ambient") + assertContains(t, index, "<kbd>f</kbd><span class=\"sno-btn-text\">flash</span>", "index.html nav hint f=flash") } // TestScrollDrivenPostSelection verifies the generated page behavior in a real @@ -622,10 +623,12 @@ func TestThemeSelection(t *testing.T) { assertContains(t, index, "theme test post", "post text") assertContains(t, index, `data-index="0"`, "data-index attribute") assertContains(t, index, `SNONUX_DEFAULT_THEME = "`+theme+`"`, "default theme baked in") + assertContains(t, index, "snonux v"+version.Version, "index.html header version") // Per-theme assets are written under themes/<theme>/. + themeDir := filepath.Join(outputDir, "themes", theme) for _, fname := range []string{"theme.css", "theme.js", "meta.json", "sounds.json"} { - path := filepath.Join(outputDir, "themes", theme, fname) + path := filepath.Join(themeDir, fname) info, err := os.Stat(path) if err != nil { t.Fatalf("theme asset missing %s: %v", path, err) @@ -665,7 +668,6 @@ func TestThemeSelection(t *testing.T) { }, } if assets, ok := fontAssets[theme]; ok { - themeDir := filepath.Join(outputDir, "themes", theme) for _, fname := range assets { path := filepath.Join(themeDir, fname) info, err := os.Stat(path) @@ -707,6 +709,34 @@ func TestThemeSelection(t *testing.T) { t.Errorf("sounds.json ambient missing %q variant for theme %q", key, theme) } } + for _, key := range []string{"normal", "wild"} { + preset, ok := ambient[key].(map[string]interface{}) + if !ok { + continue + } + if preset["file"] != "ambient.ogg" { + t.Errorf("sounds.json ambient.%s file=%v for theme %q; want ambient.ogg", key, preset["file"], theme) + } + } + for _, asset := range []string{"ambient.ogg", "MUSIC_LICENSE.txt"} { + if _, err := os.Stat(filepath.Join(themeDir, asset)); err != nil { + t.Errorf("%s missing generated %s: %v", theme, asset, err) + } + } + + metaData, err := os.ReadFile(filepath.Join(themeDir, "meta.json")) + if err != nil { + t.Fatalf("read generated meta.json: %v", err) + } + var meta struct { + HeaderHTML string `json:"header_html"` + } + if err := json.Unmarshal(metaData, &meta); err != nil { + t.Fatalf("generated meta.json invalid JSON: %v", err) + } + if !strings.Contains(meta.HeaderHTML, "snonux v"+version.Version) { + t.Errorf("%s generated meta.json header missing version", theme) + } // shared.js holds the nav logic. sharedJS := readFile(t, filepath.Join(outputDir, "shared.js")) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index aae4c97..feefe18 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -15,6 +15,7 @@ import ( "codeberg.org/snonux/snonux/internal/generator/atom" "codeberg.org/snonux/snonux/internal/generator/templates" "codeberg.org/snonux/snonux/internal/post" + "codeberg.org/snonux/snonux/internal/version" ) // pageData holds the template variables for a single HTML page. @@ -339,7 +340,7 @@ func writeThemeAsset(dir, name string) error { return fmt.Errorf("write %s/theme.js: %w", name, err) } - meta, err := templates.ThemeMeta(name) + meta, err := themeMetaJSON(name) if err != nil { return fmt.Errorf("read %s/meta.json: %w", name, err) } @@ -376,9 +377,37 @@ func loadThemeMeta(name string) (themeMeta, error) { if err := json.Unmarshal(b, &m); err != nil { return m, fmt.Errorf("parse theme meta %q: %w", name, err) } + m.HeaderHTML = headerHTMLWithVersion(m.HeaderHTML) return m, nil } +func themeMetaJSON(name string) ([]byte, error) { + m, err := loadThemeMeta(name) + if err != nil { + return nil, err + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal theme meta %q: %w", name, err) + } + return b, nil +} + +func headerHTMLWithVersion(header string) string { + if strings.Contains(header, "sno-version") { + return header + } + versionHTML := ` <span class="sno-version-sep" aria-hidden="true">·</span> <span class="sno-version">snonux v` + template.HTMLEscapeString(version.Version) + `</span>` + hostClass := `class="logo-host"` + if idx := strings.Index(header, hostClass); idx >= 0 { + if end := strings.Index(header[idx:], "</p>"); end >= 0 { + pos := idx + end + return header[:pos] + versionHTML + header[pos:] + } + } + return header + versionHTML +} + // allThemesJSON returns a JS array literal of all theme names. func allThemesJSON() (template.JS, error) { names, err := templates.ThemeNames() diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 7e2742b..a718c95 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -12,6 +12,7 @@ import ( "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/post" + "codeberg.org/snonux/snonux/internal/version" ) var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file @@ -94,6 +95,23 @@ func TestJSONStringOrNull(t *testing.T) { } } +func TestLoadThemeMetaInjectsVersion(t *testing.T) { + t.Parallel() + + for name := range getThemeSet() { + meta, err := loadThemeMeta(name) + if err != nil { + t.Fatalf("loadThemeMeta(%q): %v", name, err) + } + if !strings.Contains(meta.HeaderHTML, "snonux v"+version.Version) { + t.Errorf("theme %q header missing snonux version", name) + } + if strings.Count(meta.HeaderHTML, `class="sno-version"`) != 1 { + t.Errorf("theme %q header has duplicate version markup", name) + } + } +} + func TestThemeSoundsJSONNonEmpty(t *testing.T) { t.Parallel() j := themeSoundsJSON("neon") @@ -181,6 +199,12 @@ func TestThemeSoundPresetsAmbientValuesBounded(t *testing.T) { if a.Gain <= 0 || a.Gain > 0.15 { t.Errorf("theme %q ambient.%s gain=%f; want (0, 0.15]", name, mode, a.Gain) } + if a.File != "ambient.ogg" { + t.Errorf("theme %q ambient.%s file=%q; want ambient.ogg", name, mode, a.File) + } + if a.Volume <= 0 || a.Volume > 0.8 { + t.Errorf("theme %q ambient.%s volume=%f; want (0, 0.8]", name, mode, a.Volume) + } if a.BPM <= 0 || a.BPM > 400 { t.Errorf("theme %q ambient.%s bpm=%f; want (0, 250]", name, mode, a.BPM) } diff --git a/internal/generator/templates/embed.go b/internal/generator/templates/embed.go index 56fc673..191f4ad 100644 --- a/internal/generator/templates/embed.go +++ b/internal/generator/templates/embed.go @@ -20,6 +20,7 @@ import ( // theme.js — required, always present // meta.json — required, always present // sounds.json — required, always present +// ambient.ogg — optional, background music loop // <Family-Weight>.woff2 — optional, one or more self-hosted web fonts // <Family-Weight>.woff — optional, fallback / non-woff2 web fonts // FONT_LICENSE.txt — required iff any font file is present; @@ -34,7 +35,7 @@ import ( // match at least one file. When you introduce a new font extension // that no other theme uses yet, append the matching glob here. // -//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json themes/*/sounds.json themes/*/*.woff themes/*/*.woff2 themes/*/FONT_LICENSE.txt +//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json themes/*/sounds.json themes/*/*.ogg themes/*/*.woff themes/*/*.woff2 themes/*/FONT_LICENSE.txt themes/*/MUSIC_LICENSE.txt var FS embed.FS // themeStandardFiles lists the per-theme files that have dedicated diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl index b04b160..9759761 100644 --- a/internal/generator/templates/shared/nav.tmpl +++ b/internal/generator/templates/shared/nav.tmpl @@ -44,38 +44,65 @@ {{end}} {{define "navhints"}} -<div class="nav-hints" role="region" aria-label="Keyboard shortcuts"> +<div class="nav-hints" role="region" aria-label="Controls and shortcuts"> <div class="nav-hints-row nav-hints-nav"> <span class="nav-hints-label">nav</span> - <span><kbd>j</kbd><kbd>k</kbd><kbd>↑</kbd><kbd>↓</kbd> post</span> - <span><kbd>h</kbd><kbd>l</kbd><kbd>←</kbd><kbd>→</kbd> page</span> - <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span> - <span><kbd>Enter</kbd> open</span> - <span><kbd>Esc</kbd> close</span> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="prev-post" aria-label="Previous post"> + <i class="fas fa-arrow-up" aria-hidden="true"></i><kbd>j</kbd><kbd>↑</kbd><span class="sno-btn-text">post</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="next-post" aria-label="Next post"> + <i class="fas fa-arrow-down" aria-hidden="true"></i><kbd>k</kbd><kbd>↓</kbd><span class="sno-btn-text">post</span> + </button> + |
