summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-16 22:56:16 +0300
committerPaul Buetow <paul@buetow.org>2026-04-16 22:56:16 +0300
commit7662ae52c08c659f5aaf69f446822f48c3d0a831 (patch)
treefdfea527a24c9adf255a467d7e15257412a0b607
parentdd50c9b1d335365901467d5868ca7f026ed8209a (diff)
refactor(generator): load themes & shared HTML from embedded template files (task s4)
Move the 12 theme HTML templates out of theme_*.go (~185KB of raw-string literals) and the shared navDefs/faviconHeadHTML fragments out of shared.go/favicon.go into separate .tmpl files under internal/generator/templates/{themes,shared}/. A new templates subpackage owns a //go:embed FS and exposes Theme(), Shared() and ThemeNames() helpers so the binary still ships fully self-contained. The hand-maintained themeRegistry map is replaced by on-startup enumeration of the embedded FS; getTheme/ListThemes now derive theme names from the template files themselves, and navDefs/faviconHeadHTML are loaded once at package init. Rendered output is byte-equivalent — all unit tests and integration tests pass, go vet is clean. Made-with: Cursor
-rw-r--r--internal/generator/favicon.go19
-rw-r--r--internal/generator/generator_test.go6
-rw-r--r--internal/generator/shared.go422
-rw-r--r--internal/generator/templates.go10
-rw-r--r--internal/generator/templates/embed.go62
-rw-r--r--internal/generator/templates/shared/favicon_head.tmpl2
-rw-r--r--internal/generator/templates/shared/nav.tmpl400
-rw-r--r--internal/generator/templates/themes/aurora.tmpl (renamed from internal/generator/theme_aurora.go)9
-rw-r--r--internal/generator/templates/themes/brutalist.tmpl (renamed from internal/generator/theme_brutalist.go)9
-rw-r--r--internal/generator/templates/themes/cosmos.tmpl (renamed from internal/generator/theme_glass.go)9
-rw-r--r--internal/generator/templates/themes/dos.tmpl (renamed from internal/generator/theme_dos.go)10
-rw-r--r--internal/generator/templates/themes/matrix.tmpl (renamed from internal/generator/theme_matrix.go)11
-rw-r--r--internal/generator/templates/themes/neon.tmpl (renamed from internal/generator/theme_neon.go)9
-rw-r--r--internal/generator/templates/themes/ocean.tmpl (renamed from internal/generator/theme_ocean.go)9
-rw-r--r--internal/generator/templates/themes/plasma.tmpl (renamed from internal/generator/theme_minimal.go)9
-rw-r--r--internal/generator/templates/themes/retro.tmpl (renamed from internal/generator/theme_retro.go)11
-rw-r--r--internal/generator/templates/themes/synthwave.tmpl (renamed from internal/generator/theme_synthwave.go)9
-rw-r--r--internal/generator/templates/themes/terminal.tmpl (renamed from internal/generator/theme_terminal.go)10
-rw-r--r--internal/generator/templates/themes/volcano.tmpl (renamed from internal/generator/theme_paper.go)9
-rw-r--r--internal/generator/themes.go83
20 files changed, 582 insertions, 536 deletions
diff --git a/internal/generator/favicon.go b/internal/generator/favicon.go
index 0d2d094..a4a8251 100644
--- a/internal/generator/favicon.go
+++ b/internal/generator/favicon.go
@@ -6,14 +6,27 @@ import (
"fmt"
"image"
"image/color"
+ "log"
"os"
"path/filepath"
"strings"
+
+ "codeberg.org/snonux/snonux/internal/generator/templates"
)
-const faviconHeadHTML = `
- <link rel="icon" href="favicon.ico" sizes="any">
-`
+// 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"`) {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index e3b09bf..7b0009c 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -92,7 +92,7 @@ func TestJSONStringOrNull(t *testing.T) {
func TestThemeSoundPresetsMatchRegistry(t *testing.T) {
t.Parallel()
- for name := range themeRegistry {
+ for name := range themeSet {
if _, ok := themeSoundPresets[name]; !ok {
t.Errorf("theme %q has no sound preset in themeSoundPresets", name)
}
@@ -221,8 +221,8 @@ func TestInjectSharedHead_addsFaviconLink(t *testing.T) {
func TestListThemes_sortedAndComplete(t *testing.T) {
t.Parallel()
names := ListThemes()
- if len(names) != len(themeRegistry) {
- t.Fatalf("len=%d, want %d", len(names), len(themeRegistry))
+ if len(names) != len(themeSet) {
+ t.Fatalf("len=%d, want %d", len(names), len(themeSet))
}
for i := 1; i < len(names); i++ {
if names[i] <= names[i-1] {
diff --git a/internal/generator/shared.go b/internal/generator/shared.go
index 11dbeed..d0badb8 100644
--- a/internal/generator/shared.go
+++ b/internal/generator/shared.go
@@ -1,416 +1,30 @@
package generator
-// navDefs is appended to every theme template when parsing.
+import (
+ "log"
+
+ "codeberg.org/snonux/snonux/internal/generator/templates"
+)
+
+// navDefs is the content of templates/shared/nav.tmpl loaded once at startup.
// It defines named sub-templates shared across all themes:
// - "splashGate" — synchronous script: first child of <body>; sets html.sno-splash-skip when
// splash should not run (?splash=0, not index path, or Referer from same-site index/pageN).
// - "navhints" — keyboard shortcut hint bar HTML
-// - "navSharedCSSInner" — shared CSS (injected inside each theme’s <style> in <head>)
+// - "navSharedCSSInner" — shared CSS (injected inside each theme's <style> in <head>)
// - "navmodal" — full-screen expanded-post modal HTML (no <style>; CSS lives in head)
// - "navscript" — keyboard navigation + Web Audio; splash/nav/modal sounds from themeSoundsJSON (per theme)
//
// Each theme ends its <style> with {{template "navSharedCSSInner"}} then calls
// {{template "splashGate"}}, {{template "navhints" .}}, {{template "navmodal" .}},
// and {{template "navscript" .}} at the appropriate points in its HTML.
-// All theme-specific CSS lives in each theme file so themes stay self-contained.
-const navDefs = `
-{{define "splashGate"}}
-<script>
-(function(){
- try {
- var sp = new URLSearchParams(location.search);
- if (sp.get('splash') === '0') {
- document.documentElement.classList.add('sno-splash-skip');
- return;
- }
- } catch (_) {}
- try {
- if (/^#post-/.test(location.hash)) {
- document.documentElement.classList.add('sno-splash-skip');
- return;
- }
- } catch (_) {}
- function isIndexLikePath(pathname) {
- var p = pathname || '/';
- if (p === '' || p === '/') return true;
- var parts = p.split('/').filter(function(s) { return s.length; });
- if (parts.length === 0) return true;
- var last = parts[parts.length - 1].toLowerCase();
- if (last === 'index.html' || last === 'index.htm') return true;
- if (p.endsWith('/') && parts.length === 1) return true;
- return false;
- }
- var onIndex = isIndexLikePath(location.pathname);
- var ref = document.referrer;
- function refIsSameSiteBlogPage(url) {
- if (!url) return false;
- try {
- var ru = new URL(url), cu = new URL(location.href);
- if (ru.origin !== cu.origin) return false;
- if (isIndexLikePath(ru.pathname)) return true;
- var rp = ru.pathname.split('/').filter(function(s) { return s.length; });
- var rs = (rp.length ? rp[rp.length - 1] : '').toLowerCase();
- return /^page\d+\.html$/.test(rs);
- } catch (_) { return false; }
- }
- if (!onIndex || refIsSameSiteBlogPage(ref)) document.documentElement.classList.add('sno-splash-skip');
-})();
-</script>
-{{end}}
-
-{{define "navhints"}}
-<div class="nav-hints" role="region" aria-label="Keyboard shortcuts">
- <span><kbd>j</kbd><kbd>k</kbd> or <kbd>↑</kbd><kbd>↓</kbd> select post</span>
- <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span>
- <span><kbd>Enter</kbd> or click post to 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 "navSharedCSSInner"}}
-/* Thumbnail sizing in list view; modal overrides to full width. */
-.post-image { max-height:220px; max-width:100%; object-fit:cover; cursor:pointer; }
-#post-modal .post-image { max-height:none; width:100%; max-width:100%; object-fit:contain; cursor:default; }
-/* Semi-transparent modal backdrop so the WebGL scene stays visible behind
- the expanded post. Theme-specific modal-inner keeps its own background. */
-.post-modal { background:rgba(0,0,0,0.55) !important; backdrop-filter:blur(6px) !important; }
-#post-modal.active { display:flex !important; align-items:center; justify-content:center; }
-#post-modal .modal-inner { width:fit-content; max-width:min(100%, 90vw); max-height:calc(100vh - 80px); overflow:auto; margin:0 auto !important; }
-/* Content area max-width across all themes */
-.overlay { max-width:1200px; margin-left:auto; margin-right:auto; }
-/* Pagination: newer + older in a footer bar (below scrollable posts, like the header) */
-.page-nav-dual { display:flex; justify-content:center; align-items:center; flex-wrap:wrap;
- gap:clamp(16px,4vw,48px); }
-/* Flex column layout: let #post-content shrink so overflow-y scrolls; footer stays visible */
-#post-content.content { min-height:0; }
-.page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; }
-.page-nav-footer .page-nav { margin:0; }
-/* ~Half-height footer bar vs default .page-nav padding */
-.page-nav-footer .page-nav a { padding-top:4px; padding-bottom:4px; }
-/* Host note under the site subtitle (all themes) */
-.logo-host { font-size:0.65rem; opacity:0.55; margin-top:4px; letter-spacing:0.3px; line-height:1.3; }
-/* Atom feed link in header (paired with transmit in .nav) */
-.nav { display:flex; align-items:center; gap:clamp(10px,2.2vw,20px); flex-wrap:wrap; justify-content:flex-end; }
-a.header-feed-link { font-size:0.8rem; text-decoration:none; opacity:0.82; letter-spacing:0.04em; white-space:nowrap; }
-a.header-feed-link:hover { opacity:1; text-decoration:underline; }
-/* Header logo/title can reopen the splash overlay. */
-.logo-mark, .logo-title h1, #sn-logo { cursor:pointer; }
-/* Full-viewport splash (theme-specific colours/animation on each .splash-THEMENAME) */
-#splash-overlay { position:fixed; inset:0; z-index:2000; display:flex; flex-direction:column; align-items:center;
- justify-content:center; text-align:center; padding:max(16px,4vw); box-sizing:border-box; cursor:pointer;
- transition:opacity .55s ease, visibility .55s ease, transform .55s ease; }
-#splash-overlay.splash--dismissed { opacity:0 !important; visibility:hidden !important;
- pointer-events:none !important; transform:scale(1.02); }
-#splash-overlay:focus { outline:2px solid rgba(255,255,255,0.35); outline-offset:4px; }
-/* Vignette over WebGL so 3D motion does not overpower the edges */
-#splash-overlay::before { content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
- background: radial-gradient(ellipse 92% 82% at 50% 42%, rgba(0,0,0,0) 32%, rgba(0,0,0,0.26) 68%, rgba(0,0,0,0.48) 100%); }
-.splash-title { font-weight:700; letter-spacing:0.06em; line-height:1.15; }
-.splash-tag { margin-top:0.35rem; font-size:0.76rem; letter-spacing:0.2em; text-transform:uppercase; }
-.splash-hint { margin-top:1.25rem; font-size:0.72rem; letter-spacing:0.12em; }
-#splash-overlay .splash-gl-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; z-index:0; pointer-events:none; }
-/* Frosted panel so title/tag/hint stay readable over busy shaders */
-#splash-overlay .splash-inner { position:relative; z-index:2; max-width:min(520px,92vw);
- padding: clamp(1.15rem, 3.2vw, 1.75rem) clamp(1.3rem, 3.8vw, 1.95rem); border-radius:14px;
- background: rgba(0, 0, 0, 0.58); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
- box-shadow: 0 14px 44px rgba(0, 0, 0, 0.58), inset 0 1px 0 rgba(255, 255, 255, 0.07); }
-#splash-overlay.splash-brutalist .splash-inner.splash-frame {
- padding: clamp(1.4rem, 4.5vw, 2.25rem) clamp(1.1rem, 3.5vw, 1.9rem); background: rgba(0, 0, 0, 0.78); }
-html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidden !important; pointer-events:none !important; }
-/* Images embedded in markdown posts */
-.post-text img { max-width:100%; max-height:320px; object-fit:contain; border-radius:6px; cursor:pointer; }
-#post-modal .post-text img, #modal-content img { max-height:none; cursor:default; }
-/* Markdown post typography: restore spacing stripped by the global reset */
-.post-text p { margin:0.65em 0; }
-.post-text ul, .post-text ol { margin:0.65em 0; padding-left:1.8em; }
-.post-text li { margin:0.3em 0; }
-.post-text p:first-child { margin-top:0; }
-.post-text p:last-child, .post-text ul:last-child, .post-text ol:last-child { margin-bottom:0; }
-{{end}}
-
-{{define "navmodal"}}
-<div class="post-modal" id="post-modal" role="dialog" aria-modal="true" aria-label="Expanded post">
- <div class="modal-inner">
- <button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button>
- <div id="modal-content"></div>
- </div>
-</div>
-{{end}}
-
-{{define "navscript"}}
-<script>
- const SNONUX_SOUNDS = {{.ThemeSoundsJSON}};
- function snonuxWaveType(w) {
- if (w === 'square') return 'square';
- if (w === 'triangle') return 'triangle';
- return 'sine';
- }
- (function splashSetup() {
- var el = document.getElementById('splash-overlay');
- if (!el) return;
- var splashAudioCtx = null;
- var splashChimePlayed = false;
- function playSplashChime() {
- if (splashChimePlayed) return;
- try {
- if (!splashAudioCtx) {
- splashAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
- }
- var ctx = splashAudioCtx;
- function ring() {
- splashChimePlayed = true;
- var now = ctx.currentTime;
- var sp = SNONUX_SOUNDS.splash;
- var freqs = sp.freqs;
- var spacing = sp.spacing != null ? sp.spacing : 0.075;
- var gainAm = sp.gain != null ? sp.gain : 0.1;
- var wave = snonuxWaveType(sp.wave);
- var i, osc, g, t0;
- for (i = 0; i < freqs.length; i++) {
- osc = ctx.createOscillator();
- g = ctx.createGain();
- osc.connect(g);
- g.connect(ctx.destination);
- osc.type = wave;
- osc.frequency.value = freqs[i];
- t0 = now + i * spacing;
- g.gain.setValueAtTime(0, t0);
- g.gain.linearRampToValueAtTime(gainAm, t0 + 0.028);
- g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.52);
- osc.start(t0);
- osc.stop(t0 + 0.55);
- }
- }
- ctx.resume().then(ring).catch(function() {});
- } catch (_) {}
- }
- function dismiss() {
- if (el.classList.contains('splash--dismissed')) return;
- el.classList.add('splash--dismissed');
- el.setAttribute('aria-hidden', 'true');
- }
- function show() {
- document.documentElement.classList.remove('sno-splash-skip');
- el.classList.remove('splash--dismissed');
- el.removeAttribute('aria-hidden');
- el.focus({ preventScroll: true });
- }
- function openSplashFromHeader(e) {
- if (e.target.closest('a')) return;
- e.preventDefault();
- var modal = document.getElementById('post-modal');
- if (modal) modal.classList.remove('active');
- show();
- }
- var triggers = document.querySelectorAll('.logo-mark, .logo-title h1, #sn-logo');
- triggers.forEach(function(trigger) {
- trigger.addEventListener('click', openSplashFromHeader);
- });
- window._snonuxDismissSplash = dismiss;
- window._snonuxShowSplash = show;
- if (document.documentElement.classList.contains('sno-splash-skip')) {
- dismiss();
- return;
- }
- playSplashChime();
- el.addEventListener('pointerdown', function() { playSplashChime(); }, { passive: true });
- el.addEventListener('click', function(e) { e.preventDefault(); dismiss(); });
- el.focus({ preventScroll: true });
- })();
-
- // === KEYBOARD NAVIGATION ===
- // j / ArrowDown → next post k / ArrowUp → previous post
- // h / ArrowLeft → previous page l / ArrowRight → next page
- // PageUp/PageDown → scroll the post list; re-highlight post at top of visible area
- // Enter / click post → 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 setActiveHighlight(index, playSound, scrollIntoView) {
- 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');
- if (scrollIntoView) {
- posts[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- if (playSound) playNavSound();
- }
-
- function selectPost(index) {
- setActiveHighlight(index, true, true);
- }
-
- /** Pick the post that should be active for the current viewport (anchor near top of visible area). */
- function activeIndexForVisibleRegion(sc) {
- if (posts.length === 0) return -1;
- var scrTop, scrBot, anchorY;
- if (sc) {
- var scr = sc.getBoundingClientRect();
- scrTop = scr.top;
- scrBot = scr.bottom;
- anchorY = scr.top + Math.min(scr.height * 0.18, 100);
- } else {
- scrTop = 0;
- scrBot = window.innerHeight;
- anchorY = window.innerHeight * 0.15;
- }
- var i, pr;
- for (i = 0; i < posts.length; i++) {
- pr = posts[i].getBoundingClientRect();
- if (pr.top <= anchorY && anchorY < pr.bottom) return i;
- }
- for (i = 0; i < posts.length; i++) {
- pr = posts[i].getBoundingClientRect();
- if (pr.bottom > scrTop && pr.top < scrBot) return i;
- }
- return posts.length - 1;
- }
-
- function playNavSound() {
- try {
- var n = SNONUX_SOUNDS.nav;
- 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 = n.freq;
- osc.type = snonuxWaveType(n.wave);
- var dur = n.dur != null ? n.dur : 0.08;
- var g = n.gain != null ? n.gain : 0.12;
- gain.gain.setValueAtTime(g, ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
- osc.start(ctx.currentTime); osc.stop(ctx.currentTime + dur + 0.02);
- } catch (_) {}
- }
-
- function playOpenSound() {
- try {
- var o = SNONUX_SOUNDS.open;
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
- const osc = ctx.createOscillator();
- const gain = ctx.createGain();
- osc.connect(gain); gain.connect(ctx.destination);
- osc.type = snonuxWaveType(o.wave);
- var dur = o.dur != null ? o.dur : 0.14;
- var g = o.gain != null ? o.gain : 0.1;
- osc.frequency.setValueAtTime(o.start, ctx.currentTime);
- osc.frequency.exponentialRampToValueAtTime(o.end, ctx.currentTime + dur);
- gain.gain.setValueAtTime(g, ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur + 0.06);
- osc.start(ctx.currentTime); osc.stop(ctx.currentTime + dur + 0.07);
- } catch (_) {}
- }
-
- function playCloseSound() {
- try {
- var c = SNONUX_SOUNDS.close;
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
- const osc = ctx.createOscillator();
- const gain = ctx.createGain();
- osc.connect(gain); gain.connect(ctx.destination);
- osc.type = snonuxWaveType(c.wave);
- var dur = c.dur != null ? c.dur : 0.15;
- var g = c.gain != null ? c.gain : 0.1;
- osc.frequency.setValueAtTime(c.start, ctx.currentTime);
- osc.frequency.exponentialRampToValueAtTime(c.end, ctx.currentTime + dur);
- gain.gain.setValueAtTime(g, ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur + 0.05);
- osc.start(ctx.currentTime); osc.stop(ctx.currentTime + dur + 0.06);
- } catch (_) {}
- }
-
- function openPostAt(index, scrollIntoView) {
- if (posts.length === 0) return;
- setActiveHighlight(index, false, !!scrollIntoView);
- var post = posts[currentIndex];
- var postText = post ? post.querySelector('.post-text') : null;
- if (!postText) return;
- var modal = document.getElementById('post-modal');
- var modalInner = modal ? modal.querySelector('.modal-inner') : null;
- document.getElementById('modal-content').innerHTML = postText.innerHTML;
- modal.classList.add('active');
- modal.scrollTop = 0;
- if (modalInner) {
- modalInner.scrollTop = 0;
- requestAnimationFrame(function() {
- modalInner.scrollIntoView({ block: 'center', inline: 'nearest' });
- });
- }
- playOpenSound();
- }
-
- function closeModal() {
- document.getElementById('post-modal').classList.remove('active');
- playCloseSound();
- }
-
- (function postClickOpen() {
- posts.forEach(function(post, idx) {
- post.addEventListener('click', function(e) {
- if (e.target.closest('a, button, audio, video, input, textarea, select, label')) return;
- openPostAt(idx, true);
- });
- });
- })();
-
- (function deepLinkFromHash() {
- var h = location.hash;
- if (!h || h.indexOf('#post-') !== 0) return;
- var id = decodeURIComponent(h.slice(6));
- var el = document.getElementById('post-' + id);
- if (!el) return;
- var idx = parseInt(el.getAttribute('data-index'), 10);
- if (isNaN(idx)) return;
- openPostAt(idx, true);
- })();
-
- document.addEventListener('keydown', function(e) {
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
- var splash = document.getElementById('splash-overlay');
- if (splash && !splash.classList.contains('splash--dismissed')) {
- if (e.key === 'Enter' || e.key === ' ' || e.key === 'Escape') {
- e.preventDefault();
- if (window._snonuxDismissSplash) window._snonuxDismissSplash();
- }
- return;
- }
- if (document.getElementById('post-modal').classList.contains('active')) {
- if (e.key === 'Escape') { closeModal(); e.preventDefault(); }
- return;
- }
- switch (e.key) {
- case 'PageUp':
- case 'PageDown': {
- var sc = document.getElementById('post-content');
- var step = (sc && sc.clientHeight) ? sc.clientHeight : window.innerHeight;
- var dy = (e.key === 'PageUp') ? -step : step;
- if (sc) {
- sc.scrollTop += dy;
- } else {
- window.scrollBy(0, dy);
- }
- var idx = activeIndexForVisibleRegion(sc);
- if (idx >= 0) setActiveHighlight(idx, true, false);
- e.preventDefault();
- break;
- }
- 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': openPostAt(currentIndex, true); e.preventDefault(); break;
- }
- });
-</script>
-{{end}}
-`
+var navDefs = loadNavDefs()
+
+func loadNavDefs() string {
+ s, err := templates.Shared("nav")
+ if err != nil {
+ log.Printf("warning: could not load shared nav template: %v", err)
+ return ""
+ }
+ return s
+}
diff --git a/internal/generator/templates.go b/internal/generator/templates.go
index 5186794..f50601a 100644
--- a/internal/generator/templates.go
+++ b/internal/generator/templates.go
@@ -1,5 +1,9 @@
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.
+// HTML theme templates and shared sub-templates live as separate files under
+// internal/generator/templates/{themes,shared}/*.tmpl and are loaded into the
+// binary via embed.FS; see internal/generator/templates/embed.go.
+//
+// themes.go exposes the theme registry and getTheme()/ListThemes() helpers.
+// shared.go loads templates/shared/nav.tmpl into navDefs.
+// favicon.go loads templates/shared/favicon_head.tmpl into faviconHeadHTML.
diff --git a/internal/generator/templates/embed.go b/internal/generator/templates/embed.go
new file mode 100644
index 0000000..91ba07f
--- /dev/null
+++ b/internal/generator/templates/embed.go
@@ -0,0 +1,62 @@
+// Package templates exposes the embedded HTML theme and shared sub-templates
+// used by the generator. Themes and nav/HTML fragments live as separate files
+// under templates/themes/*.tmpl and templates/shared/*.tmpl so they can be
+// edited without recompiling logic; they are compiled into the binary via
+// //go:embed so snonux still ships as a single self-contained executable.
+package templates
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "path"
+ "sort"
+ "strings"
+)
+
+//go:embed themes/*.tmpl shared/*.tmpl
+var FS embed.FS
+
+// Theme reads the raw HTML template body for a named theme.
+// The returned string is the outer page template; shared sub-templates
+// (navhints, navmodal, navscript, etc.) are obtained via Shared().
+func Theme(name string) (string, error) {
+ b, err := FS.ReadFile(path.Join("themes", name+".tmpl"))
+ if err != nil {
+ return "", fmt.Errorf("read theme %q: %w", name, err)
+ }
+ return string(b), nil
+}
+
+// Shared reads a named shared sub-template file from shared/*.tmpl.
+func Shared(name string) (string, error) {
+ b, err := FS.ReadFile(path.Join("shared", name+".tmpl"))
+ if err != nil {
+ return "", fmt.Errorf("read shared template %q: %w", name, err)
+ }
+ return string(b), nil
+}
+
+// ThemeNames returns a sorted list of available theme names derived from the
+// files present under templates/themes/.
+func ThemeNames() ([]string, error) {
+ entries, err := fs.ReadDir(FS, "themes")
+ if err != nil {
+ return nil, fmt.Errorf("list theme dir: %w", err)
+ }
+
+ names := make([]string, 0, len(entries))
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ n := e.Name()
+ if !strings.HasSuffix(n, ".tmpl") {
+ continue
+ }
+ names = append(names, strings.TrimSuffix(n, ".tmpl"))
+ }
+
+ sort.Strings(names)
+ return names, nil
+}
diff --git a/internal/generator/templates/shared/favicon_head.tmpl b/internal/generator/templates/shared/favicon_head.tmpl
new file mode 100644
index 0000000..e35bd5d
--- /dev/null
+++ b/internal/generator/templates/shared/favicon_head.tmpl
@@ -0,0 +1,2 @@
+
+ <link rel="icon" href="favicon.ico" sizes="any">
diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl
new file mode 100644
index 0000000..0bbbf61
--- /dev/null
+++ b/internal/generator/templates/shared/nav.tmpl
@@ -0,0 +1,400 @@
+
+{{define "splashGate"}}
+<script>
+(function(){
+ try {
+ var sp = new URLSearchParams(location.search);
+ if (sp.get('splash') === '0') {
+ document.documentElement.classList.add('sno-splash-skip');
+ return;
+ }
+ } catch (_) {}
+ try {
+ if (/^#post-/.test(location.hash)) {
+ document.documentElement.classList.add('sno-splash-skip');
+ return;
+ }
+ } catch (_) {}
+ function isIndexLikePath(pathname) {
+ var p = pathname || '/';
+ if (p === '' || p === '/') return true;
+ var parts = p.split('/').filter(function(s) { return s.length; });
+ if (parts.length === 0) return true;
+ var last = parts[parts.length - 1].toLowerCase();
+ if (last === 'index.html' || last === 'index.htm') return true;
+ if (p.endsWith('/') && parts.length === 1) return true;
+ return false;
+ }
+ var onIndex = isIndexLikePath(location.pathname);
+ var ref = document.referrer;
+ function refIsSameSiteBlogPage(url) {
+ if (!url) return false;
+ try {
+ var ru = new URL(url), cu = new URL(location.href);
+ if (ru.origin !== cu.origin) return false;
+ if (isIndexLikePath(ru.pathname)) return true;
+ var rp = ru.pathname.split('/').filter(function(s) { return s.length; });
+ var rs = (rp.length ? rp[rp.length - 1] : '').toLowerCase();
+ return /^page\d+\.html$/.test(rs);
+ } catch (_) { return false; }
+ }
+ if (!onIndex || refIsSameSiteBlogPage(ref)) document.documentElement.classList.add('sno-splash-skip');
+})();
+</script>
+{{end}}
+
+{{define "navhints"}}
+<div class="nav-hints" role="region" aria-label="Keyboard shortcuts">
+ <span><kbd>j</kbd><kbd>k</kbd> or <kbd>↑</kbd><kbd>↓</kbd> select post</span>
+ <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span>
+ <span><kbd>Enter</kbd> or click post to 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 "navSharedCSSInner"}}
+/* Thumbnail sizing in list view; modal overrides to full width. */
+.post-image { max-height:220px; max-width:100%; object-fit:cover; cursor:pointer; }
+#post-modal .post-image { max-height:none; width:100%; max-width:100%; object-fit:contain; cursor:default; }
+/* Semi-transparent modal backdrop so the WebGL scene stays visible behind
+ the expanded post. Theme-specific modal-inner keeps its own background. */
+.post-modal { background:rgba(0,0,0,0.55) !important; backdrop-filter:blur(6px) !important; }
+#post-modal.active { display:flex !important; align-items:center; justify-content:center; }
+#post-modal .modal-inner { width:fit-content; max-width:min(100%, 90vw); max-height:calc(100vh - 80px); overflow:auto; margin:0 auto !important; }
+/* Content area max-width across all themes */
+.overlay { max-width:1200px; margin-left:auto; margin-right:auto; }
+/* Pagination: newer + older in a footer bar (below scrollable posts, like the header) */
+.page-nav-dual { display:flex; justify-content:center; align-items:center; flex-wrap:wrap;
+ gap:clamp(16px,4vw,48px); }
+/* Flex column layout: let #post-content shrink so overflow-y scrolls; footer stays visible */
+#post-content.content { min-height:0; }
+.page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; }
+.page-nav-footer .page-nav { margin:0; }
+/* ~Half-height footer bar vs default .page-nav padding */
+.page-nav-footer .page-nav a { padding-top:4px; padding-bottom:4px; }
+/* Host note under the site subtitle (all themes) */
+.logo-host { font-size:0.65rem; opacity:0.55; margin-top:4px; letter-spacing:0.3px; line-height:1.3; }
+/* Atom feed link in header (paired with transmit in .nav) */
+.nav { display:flex; align-items:center; gap:clamp(10px,2.2vw,20px); flex-wrap:wrap; justify-content:flex-end; }
+a.header-feed-link { font-size:0.8rem; text-decoration:none; opacity:0.82; letter-spacing:0.04em; white-space:nowrap; }
+a.header-feed-link:hover { opacity:1; text-decoration:underline; }
+/* Header logo/title can reopen the splash overlay. */
+.logo-mark, .logo-title h1, #sn-logo { cursor:pointer; }
+/* Full-viewport splash (theme-specific colours/animation on each .splash-THEMENAME) */
+#splash-overlay { position:fixed; inset:0; z-index:2000; display:flex; flex-direction:column; align-items:center;
+ justify-content:center; text-align:center; padding:max(16px,4vw); box-sizing:border-box; cursor:pointer;
+ transition:opacity .55s ease, visibility .55s ease, transform .55s ease; }
+#splash-overlay.splash--dismissed { opacity:0 !important; visibility:hidden !important;
+ pointer-events:none !important; transform:scale(1.02); }
+#splash-overlay:focus { outline:2px solid rgba(255,255,255,0.35); outline-offset:4px; }
+/* Vignette over WebGL so 3D motion does not overpower the edges */
+#splash-overlay::before { content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
+ background: radial-gradient(ellipse 92% 82% at 50% 42%, rgba(0,0,0,0) 32%, rgba(0,0,0,0.26) 68%, rgba(0,0,0,0.48) 100%); }
+.splash-title { font-weight:700; letter-spacing:0.06em; line-height:1.15; }
+.splash-tag { margin-top:0.35rem; font-size:0.76rem; letter-spacing:0.2em; text-transform:uppercase; }
+.splash-hint { margin-top:1.25rem; font-size:0.72rem; letter-spacing:0.12em; }
+#splash-overlay .splash-gl-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; z-index:0; pointer-events:none; }
+/* Frosted panel so title/tag/hint stay readable over busy shaders */
+#splash-overlay .splash-inner { position:relative; z-index:2; max-width:min(520px,92vw);
+ padding: clamp(1.15rem, 3.2vw, 1.75rem) clamp(1.3rem, 3.8vw, 1.95rem); border-radius:14px;
+ background: rgba(0, 0, 0, 0.58); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
+ box-shadow: 0 14px 44px rgba(0, 0, 0, 0.58), inset 0 1px 0 rgba(255, 255, 255, 0.07); }
+#splash-overlay.splash-brutalist .splash-inner.splash-frame {
+ padding: clamp(1.4rem, 4.5vw, 2.25rem) clamp(1.1rem, 3.5vw, 1.9rem); background: rgba(0, 0, 0, 0.78); }
+html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidden !important; pointer-events:none !important; }
+/* Images embedded in markdown posts */
+.post-text img { max-width:100%; max-height:320px; object-fit:contain; border-radius:6px; cursor:pointer; }
+#post-modal .post-text img, #modal-content img { max-height:none; cursor:default; }
+/* Markdown post typography: restore spacing stripped by the global reset */