diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-16 22:56:16 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-16 22:56:16 +0300 |
| commit | 7662ae52c08c659f5aaf69f446822f48c3d0a831 (patch) | |
| tree | fdfea527a24c9adf255a467d7e15257412a0b607 | |
| parent | dd50c9b1d335365901467d5868ca7f026ed8209a (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
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 */ |
