diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-11 23:02:09 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-11 23:02:09 +0300 |
| commit | 948f652fabc810ba8166727ba9f0daed555830d7 (patch) | |
| tree | cf5beaee706ab287c3700057daf04c6879616317 | |
| parent | 73a41c7e2fab3a125ab318934b7d486744b66eb3 (diff) | |
Release v0.1.6v0.1.6
| -rw-r--r-- | Magefile.go | 12 | ||||
| -rw-r--r-- | internal/generator/atom/atom.go | 6 | ||||
| -rw-r--r-- | internal/generator/atom/atom_test.go | 2 | ||||
| -rw-r--r-- | internal/generator/favicon.go | 211 | ||||
| -rw-r--r-- | internal/generator/generator.go | 8 | ||||
| -rw-r--r-- | internal/generator/generator_test.go | 20 | ||||
| -rw-r--r-- | internal/generator/shared.go | 101 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
8 files changed, 333 insertions, 29 deletions
diff --git a/Magefile.go b/Magefile.go index e2908a5..64eafe4 100644 --- a/Magefile.go +++ b/Magefile.go @@ -12,6 +12,9 @@ import ( "github.com/magefile/mage/mg" ) +// Default runs when `mage` is invoked with no arguments (same as `mage build`). +var Default = Build + // Build compiles the snonux binary for the current platform. func Build() error { fmt.Println("Building snonux...") @@ -21,6 +24,15 @@ func Build() error { return cmd.Run() } +// Install compiles and installs snonux to $GOBIN, $GOPATH/bin, or the default Go bin path. +func Install() error { + fmt.Println("Installing snonux...") + cmd := exec.Command("go", "install", "./cmd/snonux") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // Dev builds snonux with race detection enabled. Runs Vet and Lint first. func Dev() error { mg.Deps(Vet, Lint) 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" |
