summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-11 23:02:09 +0300
committerPaul Buetow <paul@buetow.org>2026-04-11 23:02:09 +0300
commit948f652fabc810ba8166727ba9f0daed555830d7 (patch)
treecf5beaee706ab287c3700057daf04c6879616317 /internal
parent73a41c7e2fab3a125ab318934b7d486744b66eb3 (diff)
Release v0.1.6v0.1.6
Diffstat (limited to 'internal')
-rw-r--r--internal/generator/atom/atom.go6
-rw-r--r--internal/generator/atom/atom_test.go2
-rw-r--r--internal/generator/favicon.go211
-rw-r--r--internal/generator/generator.go8
-rw-r--r--internal/generator/generator_test.go20
-rw-r--r--internal/generator/shared.go101
-rw-r--r--internal/version/version.go2
7 files changed, 321 insertions, 29 deletions
diff --git a/internal/generator/atom/atom.go b/internal/generator/atom/atom.go
index f57ad0d..75f4876 100644
--- a/internal/generator/atom/atom.go
+++ b/internal/generator/atom/atom.go
@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"codeberg.org/snonux/snonux/internal/config"
@@ -76,8 +77,11 @@ func Generate(posts []*post.Post, cfg *config.Config) error {
func buildEntries(posts []*post.Post, baseURL string) []entry {
entries := make([]entry, 0, len(posts))
+ base := strings.TrimSuffix(baseURL, "/")
for _, p := range posts {
- entryURL := fmt.Sprintf("%s/posts/%s/", baseURL, p.ID)
+ // Link to the main HTML feed page, not /posts/<id>/ (no per-post HTML there;
+ // asset URLs in content are root-relative, e.g. posts/<id>/image.jpg).
+ entryURL := fmt.Sprintf("%s/#post-%s", base, p.ID)
entries = append(entries, entry{
Title: fmt.Sprintf("Post %s", p.ID),
Link: link{Href: entryURL, Rel: "alternate"},
diff --git a/internal/generator/atom/atom_test.go b/internal/generator/atom/atom_test.go
index bf705c3..2bfdfb7 100644
--- a/internal/generator/atom/atom_test.go
+++ b/internal/generator/atom/atom_test.go
@@ -40,7 +40,7 @@ func TestGenerate_writesAtomXML(t *testing.T) {
if !strings.Contains(s, `xmlns="http://www.w3.org/2005/Atom"`) {
t.Fatalf("missing atom xmlns: %s", s)
}
- if !strings.Contains(s, "https://example.test/posts/p1/") {
+ if !strings.Contains(s, "https://example.test/#post-p1") {
t.Fatalf("missing entry link: %s", s)
}
if !strings.Contains(s, "hello") || !strings.Contains(s, `type="html"`) {
diff --git a/internal/generator/favicon.go b/internal/generator/favicon.go
new file mode 100644
index 0000000..0d2d094
--- /dev/null
+++ b/internal/generator/favicon.go
@@ -0,0 +1,211 @@
+package generator
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "image"
+ "image/color"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const faviconHeadHTML = `
+ <link rel="icon" href="favicon.ico" sizes="any">
+`
+
+func injectSharedHead(theme string) string {
+ if strings.Contains(theme, `rel="icon"`) {
+ return theme
+ }
+
+ return strings.Replace(theme, "</head>", faviconHeadHTML+"</head>", 1)
+}
+
+func writeFavicon(outputDir string) error {
+ data, err := generateFaviconICO()
+ if err != nil {
+ return fmt.Errorf("generate favicon.ico: %w", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(outputDir, "favicon.ico"), data, 0o644); err != nil {
+ return fmt.Errorf("write favicon.ico: %w", err)
+ }
+
+ return nil
+}
+
+func generateFaviconICO() ([]byte, error) {
+ const size = 32
+
+ img := image.NewRGBA(image.Rect(0, 0, size, size))
+ black := color.RGBA{R: 8, G: 10, B: 12, A: 255}
+ green := color.RGBA{R: 0, G: 255, B: 102, A: 255}
+ cyan := color.RGBA{R: 0, G: 214, B: 255, A: 255}
+
+ fillRect(img, 0, 0, size, size, black)
+ fillRect(img, 1, 1, size-1, 2, green)
+ fillRect(img, 1, size-2, size-1, size-1, green)
+ fillRect(img, 1, 1, 2, size-1, green)
+ fillRect(img, size-2, 1, size-1, size-1, cyan)
+
+ // Left glyph: blocky "S".
+ fillRect(img, 5, 5, 13, 8, green)
+ fillRect(img, 5, 8, 8, 14, green)
+ fillRect(img, 5, 14, 13, 17, green)
+ fillRect(img, 10, 17, 13, 23, green)
+ fillRect(img, 5, 23, 13, 26, green)
+
+ // Right glyph: blocky "N".
+ fillRect(img, 18, 5, 21, 26, cyan)
+ fillRect(img, 25, 5, 28, 26, cyan)
+ for i := 0; i < 7; i++ {
+ x := 20 + i
+ y := 6 + i*3
+ fillRect(img, x, y, x+2, y+3, cyan)
+ }
+
+ return encodeICO(img)
+}
+
+func fillRect(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
+ bounds := img.Bounds()
+ if x0 < bounds.Min.X {
+ x0 = bounds.Min.X
+ }
+ if y0 < bounds.Min.Y {
+ y0 = bounds.Min.Y
+ }
+ if x1 > bounds.Max.X {
+ x1 = bounds.Max.X
+ }
+ if y1 > bounds.Max.Y {
+ y1 = bounds.Max.Y
+ }
+
+ for y := y0; y < y1; y++ {
+ for x := x0; x < x1; x++ {
+ img.SetRGBA(x, y, c)
+ }
+ }
+}
+
+func encodeICO(img image.Image) ([]byte, error) {
+ b := img.Bounds()
+ if b.Dx() != 32 || b.Dy() != 32 {
+ return nil, fmt.Errorf("favicon must be 32x32, got %dx%d", b.Dx(), b.Dy())
+ }
+
+ const (
+ bitmapHeaderSize = 40
+ icoHeaderSize = 6
+ dirEntrySize = 16
+ bitsPerPixel = 32
+ )
+
+ maskRowSize := ((b.Dx() + 31) / 32) * 4
+ maskSize := maskRowSize * b.Dy()
+ pixelSize := b.Dx() * b.Dy() * 4
+ imageSize := bitmapHeaderSize + pixelSize + maskSize
+ imageOffset := icoHeaderSize + dirEntrySize
+
+ var buf bytes.Buffer
+
+ write := func(v any) error {
+ return binary.Write(&buf, binary.LittleEndian, v)
+ }
+
+ if err := write(uint16(0)); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(1)); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(1)); err != nil {
+ return nil, err
+ }
+
+ if err := buf.WriteByte(byte(b.Dx())); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(byte(b.Dy())); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(0); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(0); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(1)); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(bitsPerPixel)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(imageSize)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(imageOffset)); err != nil {
+ return nil, err
+ }
+
+ if err := write(uint32(bitmapHeaderSize)); err != nil {
+ return nil, err
+ }
+ if err := write(int32(b.Dx())); err != nil {
+ return nil, err
+ }
+ if err := write(int32(b.Dy() * 2)); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(1)); err != nil {
+ return nil, err
+ }
+ if err := write(uint16(bitsPerPixel)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(0)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(pixelSize + maskSize)); err != nil {
+ return nil, err
+ }
+ if err := write(int32(0)); err != nil {
+ return nil, err
+ }
+ if err := write(int32(0)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(0)); err != nil {
+ return nil, err
+ }
+ if err := write(uint32(0)); err != nil {
+ return nil, err
+ }
+
+ for y := b.Max.Y - 1; y >= b.Min.Y; y-- {
+ for x := b.Min.X; x < b.Max.X; x++ {
+ r, g, bl, a := img.At(x, y).RGBA()
+ if err := buf.WriteByte(byte(bl >> 8)); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(byte(g >> 8)); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(byte(r >> 8)); err != nil {
+ return nil, err
+ }
+ if err := buf.WriteByte(byte(a >> 8)); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if _, err := buf.Write(make([]byte, maskSize)); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3d1a441..dc1f236 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -27,6 +27,7 @@ type pageData struct {
// postView is a render-friendly representation of a post for the HTML template.
type postView struct {
+ ID string
FormattedTime string
ContentHTML template.HTML // pre-rendered; trusted — generated by this tool
}
@@ -47,12 +48,16 @@ func Run(cfg *config.Config) error {
// Combine the theme HTML (which uses {{template "navhints"}} etc.) with the
// shared navDefs sub-templates so a single parse call resolves all references.
- combined := getTheme(cfg.Theme) + "\n" + navDefs
+ combined := injectSharedHead(getTheme(cfg.Theme)) + "\n" + navDefs
tmpl, err := template.New("page").Parse(combined)
if err != nil {
return fmt.Errorf("parse page template: %w", err)
}
+ if err := writeFavicon(cfg.OutputDir); err != nil {
+ return err
+ }
+
for i, page := range pages {
if err := writePage(tmpl, page, i, len(pages), cfg); err != nil {
return err
@@ -145,6 +150,7 @@ func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string)
views := make([]postView, len(posts))
for i, p := range posts {
views[i] = postView{
+ ID: p.ID,
FormattedTime: formatPostTime(p.Timestamp),
ContentHTML: template.HTML(p.Content), //nolint:gosec // content is tool-generated HTML
}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 9eafb14..e3b09bf 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4,6 +4,7 @@ import (
"html/template"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -208,6 +209,15 @@ func TestGetTheme_unknownFallsBackToNeon(t *testing.T) {
}
}
+func TestInjectSharedHead_addsFaviconLink(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")
+ }
+}
+
func TestListThemes_sortedAndComplete(t *testing.T) {
t.Parallel()
names := ListThemes()
@@ -264,4 +274,14 @@ func TestRun_writesPagesAndAtom(t *testing.T) {
if _, err := os.Stat(filepath.Join(out, "atom.xml")); err != nil {
t.Fatalf("atom.xml: %v", err)
}
+ if _, err := os.Stat(filepath.Join(out, "favicon.ico")); err != nil {
+ t.Fatalf("favicon.ico: %v", err)
+ }
+ indexHTML, err := os.ReadFile(filepath.Join(out, "index.html"))
+ if err != nil {
+ t.Fatalf("read index.html: %v", err)
+ }
+ if !strings.Contains(string(indexHTML), `rel="icon" href="favicon.ico"`) {
+ t.Fatalf("index.html missing favicon link: %s", string(indexHTML))
+ }
}
diff --git a/internal/generator/shared.go b/internal/generator/shared.go
index b7cd4ed..9b927f7 100644
--- a/internal/generator/shared.go
+++ b/internal/generator/shared.go
@@ -24,6 +24,12 @@ const navDefs = `
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;
@@ -56,7 +62,7 @@ const navDefs = `
<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> expand</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>
@@ -69,6 +75,8 @@ const navDefs = `
/* 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:min(100%, 800px); max-height:calc(100vh - 80px); overflow-y: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) */
@@ -86,6 +94,8 @@ const navDefs = `
.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;
@@ -130,14 +140,6 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
(function splashSetup() {
var el = document.getElementById('splash-overlay');
if (!el) return;
- if (document.documentElement.classList.contains('sno-splash-skip')) {
- if (typeof window._snonuxSplashWebGLCleanup === 'function') {
- try { window._snonuxSplashWebGLCleanup(); } catch (_) {}
- window._snonuxSplashWebGLCleanup = null;
- }
- el.remove();
- return;
- }
var splashAudioCtx = null;
var splashChimePlayed = false;
function playSplashChime() {
@@ -174,20 +176,37 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
ctx.resume().then(ring).catch(function() {});
} catch (_) {}
}
- playSplashChime();
- el.addEventListener('pointerdown', function() { playSplashChime(); }, { passive: true });
function dismiss() {
if (el.classList.contains('splash--dismissed')) return;
- playSplashChime();
- if (typeof window._snonuxSplashWebGLCleanup === 'function') {
- try { window._snonuxSplashWebGLCleanup(); } catch (_) {}
- window._snonuxSplashWebGLCleanup = null;
- }
el.classList.add('splash--dismissed');
- setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 600);
+ el.setAttribute('aria-hidden', 'true');
}
- el.addEventListener('click', function(e) { e.preventDefault(); dismiss(); });
+ 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 });
})();
@@ -195,7 +214,7 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
// 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 → expand modal Esc → close modal
+ // Enter / click post → expand modal Esc → close modal
const posts = document.querySelectorAll('.post');
let currentIndex = posts.length > 0 ? 0 : -1;
const prevPageURL = {{.PrevPageJSON}};
@@ -297,11 +316,23 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
} catch (_) {}
}
- function openModal() {
- if (currentIndex < 0) return;
- document.getElementById('modal-content').innerHTML =
- posts[currentIndex].querySelector('.post-text').innerHTML;
- document.getElementById('post-modal').classList.add('active');
+ function 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();
}
@@ -310,6 +341,26 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
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');
@@ -348,7 +399,7 @@ html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidde
case 'l': case 'ArrowRight':
if (nextPageURL) { playNavSound(); window.location.href = nextPageURL; }
e.preventDefault(); break;
- case 'Enter': openModal(); e.preventDefault(); break;
+ case 'Enter': openPostAt(currentIndex, true); e.preventDefault(); break;
}
});
</script>
diff --git a/internal/version/version.go b/internal/version/version.go
index 0a14c44..3f21bc2 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -2,4 +2,4 @@
package version
// Version is the application version (semantic versioning).
-const Version = "0.1.5"
+const Version = "0.1.6"