From 5b63e3040cb152678ce8300f61bbf5a32081c282 Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:05:40 +0545 Subject: [PATCH 1/6] feat(serve): inject sw.js CACHE_VERSION at startup from build version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #91. Embedded sw.js ships __MARKGO_CACHE_VERSION__ placeholder substituted once at startup with `swCacheVersion(constants.AppVersion)` (strips leading v, "dev" fallback). Operator STATIC_PATH overlay sw.js serves raw — operator owns their cache version, bypasses auto-bump. Build invariant: missing placeholder fails server startup. --- docs/configuration.md | 7 ++ internal/commands/serve/command.go | 19 ++- internal/commands/serve/overlay.go | 44 ++++--- internal/commands/serve/overlay_test.go | 28 +++-- internal/commands/serve/sw.go | 56 +++++++++ internal/commands/serve/sw_test.go | 161 ++++++++++++++++++++++++ web/static/sw.js | 8 +- 7 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 internal/commands/serve/sw.go create mode 100644 internal/commands/serve/sw_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 5e055e4..6507bf5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,6 +117,13 @@ Drop your SVG at `/img/brand-logo.svg`. markgo reads it at startup | `/img/og-article-default.png` | Per-article OG fallback | | `/fonts/...` | Custom web fonts (referenced via `@font-face` in CSS) | | `/css/.css` | Custom theme stylesheet (any `BLOG_STYLE` name accepted) | +| `/sw.js` | Custom service worker — operator owns their `CACHE_VERSION` and bypasses the build-version auto-bump (see [Service worker cache version](#service-worker-cache-version-v3170)) | + +### Service worker cache version (v3.17.0+) + +The embedded `sw.js` declares `CACHE_VERSION` via a placeholder substituted at server startup from the running build's semver (e.g. `3.17.0`; `dev` for unstamped builds). Cache names read `markgo-precache-v3.17.0`, etc. Each release auto-invalidates client caches via the activate handler's generic `markgo-*` prefix cleanup. + +Operators overriding `sw.js` via STATIC_PATH ship **raw bytes** — substitution is skipped and the operator's hardcoded `CACHE_VERSION` is served verbatim. The operator owns their cache version and is responsible for bumping it. ## Pages (v3.13.0+) diff --git a/internal/commands/serve/command.go b/internal/commands/serve/command.go index c3d4055..add5c63 100644 --- a/internal/commands/serve/command.go +++ b/internal/commands/serve/command.go @@ -349,10 +349,21 @@ func setupRoutes(router *gin.Engine, h *handlers.Router, sessionStore *middlewar } router.StaticFS("/static", &gin.OnlyFilesFS{FileSystem: staticFS}) - // sw.js: served from root for SW scope, follows the same overlay - // resolution. See serveSwJs in overlay.go for why this can't route through - // c.FileFromFS without a directory-redirect loop. - registerGET(router, "/sw.js", serveSwJs(staticFS)) + + // sw.js: substituted-version embedded body cached at startup; operator + // overlay at /sw.js serves raw bytes (operator owns their + // cache version, bypassing auto-bump). Startup fail-loud if the embedded + // placeholder is missing — build invariant. + swBody, swModTime, swErr := loadServiceWorker(staticSub, swCacheVersion(constants.AppVersion)) + if swErr != nil { + logger.Error("Failed to load embedded sw.js — cannot start server", "error", swErr) + os.Exit(1) + } + var swLocalFS http.FileSystem + if dirExists(cfg.StaticPath) { + swLocalFS = http.Dir(cfg.StaticPath) + } + registerGET(router, "/sw.js", serveSwJs(swLocalFS, swBody, swModTime)) // Uploaded assets — filesystem only, never embedded if cfg.Upload.Path != "" { if err := os.MkdirAll(cfg.Upload.Path, 0o755); err != nil { //nolint:gosec // upload dir needs to be accessible diff --git a/internal/commands/serve/overlay.go b/internal/commands/serve/overlay.go index a8f9d0b..e2cca65 100644 --- a/internal/commands/serve/overlay.go +++ b/internal/commands/serve/overlay.go @@ -13,10 +13,12 @@ package serve import ( + "bytes" "errors" "io/fs" "log/slog" "net/http" + "time" "github.com/gin-gonic/gin" ) @@ -45,24 +47,32 @@ func (o *overlayFS) Open(name string) (http.File, error) { return o.embedded.Open(name) } -// serveSwJs serves sw.js from the given FS, with explicit directory rejection. -// Necessary because c.FileFromFS routes through http.FileServer, which redirects -// on directories — and gin's RedirectTrailingSlash would loop that back. The -// /static/* mount avoids this because gin's createStaticHandler has an explicit -// *OnlyFilesFS type-check; the /sw.js route bypasses that path. -func serveSwJs(staticFS http.FileSystem) func(c *gin.Context) { +// serveSwJs serves sw.js with overlay precedence: an operator-supplied +// /sw.js (localFS) wins as raw bytes; otherwise the embedded +// copy with __MARKGO_CACHE_VERSION__ substituted at startup is served from +// the cached substitutedBody. localFS may be nil when STATIC_PATH is unset +// or missing. +// +// Operator-overlay sw.js bypasses the build-version auto-bump (the operator +// owns their cache version). This is intentional; documented in +// docs/configuration.md. +// +// Explicit directory rejection on the local path mirrors the prior shape: +// c.FileFromFS routes through http.FileServer which redirects on +// directories, and gin's RedirectTrailingSlash would loop that back. The +// /static/* mount avoids this because gin's createStaticHandler has an +// explicit *OnlyFilesFS type-check; the /sw.js route bypasses that path. +func serveSwJs(localFS http.FileSystem, substitutedBody []byte, modTime time.Time) func(c *gin.Context) { return func(c *gin.Context) { - f, err := staticFS.Open("sw.js") - if err != nil { - c.Status(http.StatusNotFound) - return - } - defer f.Close() - stat, err := f.Stat() - if err != nil || stat.IsDir() { - c.Status(http.StatusNotFound) - return + if localFS != nil { + if f, err := localFS.Open("sw.js"); err == nil { + defer f.Close() + if stat, sErr := f.Stat(); sErr == nil && !stat.IsDir() { + http.ServeContent(c.Writer, c.Request, "sw.js", stat.ModTime(), f) + return + } + } } - http.ServeContent(c.Writer, c.Request, "sw.js", stat.ModTime(), f) + http.ServeContent(c.Writer, c.Request, "sw.js", modTime, bytes.NewReader(substitutedBody)) } } diff --git a/internal/commands/serve/overlay_test.go b/internal/commands/serve/overlay_test.go index 60b7ed9..f474f2d 100644 --- a/internal/commands/serve/overlay_test.go +++ b/internal/commands/serve/overlay_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" "testing/fstest" + "time" "github.com/gin-gonic/gin" ) @@ -125,7 +126,9 @@ func TestStaticMount_OverlayIntegration(t *testing.T) { r := gin.New() overlay := newOverlayFS(http.Dir(localDir), http.FS(embedded), discardLogger()) r.StaticFS("/static", &gin.OnlyFilesFS{FileSystem: overlay}) - swHandler := serveSwJs(overlay) + // New signature: localFS for raw-passthrough, substituted body fallback. + // Test substituted body uses literal "EMBEDDED-SW" so existing assertions hold. + swHandler := serveSwJs(http.Dir(localDir), []byte("EMBEDDED-SW"), time.Now()) r.GET("/sw.js", swHandler) r.HEAD("/sw.js", swHandler) @@ -233,21 +236,20 @@ func TestStaticMount_NoDirectoryListings_BothBranches(t *testing.T) { }) } -// TestStaticMount_SwJsRejectsDirectory: if an operator misconfigures -// /sw.js as a directory rather than a file, the sw.js handler -// must return 404 — not redirect-loop into gin's trailing-slash handler nor -// expose a listing via http.FileServer's default behavior. -func TestStaticMount_SwJsRejectsDirectory(t *testing.T) { +// TestStaticMount_SwJsDirectoryFallsBackToEmbedded: if an operator +// misconfigures /sw.js as a directory rather than a file, +// the sw.js handler falls back to the substituted embedded body rather +// than 404'ing. More resilient than the pre-v3.17.0 behavior (which +// returned 404) — operator misconfig no longer breaks SW registration. +func TestStaticMount_SwJsDirectoryFallsBackToEmbedded(t *testing.T) { localDir := t.TempDir() if err := os.MkdirAll(filepath.Join(localDir, "sw.js"), 0o755); err != nil { t.Fatal(err) } - embedded := fstest.MapFS{} gin.SetMode(gin.TestMode) r := gin.New() - overlay := newOverlayFS(http.Dir(localDir), http.FS(embedded), discardLogger()) - r.GET("/sw.js", serveSwJs(overlay)) + r.GET("/sw.js", serveSwJs(http.Dir(localDir), []byte("EMBEDDED-FALLBACK"), time.Now())) srv := httptest.NewServer(r) defer srv.Close() @@ -257,8 +259,12 @@ func TestStaticMount_SwJsRejectsDirectory(t *testing.T) { t.Fatalf("GET /sw.js: %v", err) } defer resp.Body.Close() - if resp.StatusCode != 404 { - t.Errorf("GET /sw.js (dir at /sw.js): status %d, want 404", resp.StatusCode) + if resp.StatusCode != 200 { + t.Errorf("GET /sw.js (dir at /sw.js): status %d, want 200 (falls back to embedded)", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "EMBEDDED-FALLBACK" { + t.Errorf("body %q, want EMBEDDED-FALLBACK", string(body)) } } diff --git a/internal/commands/serve/sw.go b/internal/commands/serve/sw.go new file mode 100644 index 0000000..450f12f --- /dev/null +++ b/internal/commands/serve/sw.go @@ -0,0 +1,56 @@ +// Package serve — service worker version injection. +// +// loadServiceWorker reads the embedded sw.js once at startup, substitutes +// the cache-version placeholder with the running build's semver, and +// returns the resulting bytes for cached serving. The placeholder is +// asserted present so a future templates refactor that drops it fails +// loud at startup rather than silently shipping un-versioned caches. +// +// Operator overlay precedence: see serveSwJs in overlay.go. An operator +// drop at /sw.js serves raw — the operator owns their cache +// version and the auto-bump path does not touch their bytes. +package serve + +import ( + "bytes" + "fmt" + "io/fs" + "strings" + "time" +) + +const cacheVersionPlaceholder = "__MARKGO_CACHE_VERSION__" + +// swCacheVersion derives the cache-version string from a build-time semver. +// Strips a leading "v" so cache names read e.g. "markgo-precache-v3.17.0" +// rather than "markgo-precache-vv3.17.0". Empty input falls back to "dev" +// so unstamped builds (make dev / air live-reload) still produce valid +// cache names; the per-instance staleness during dev iteration is +// resolved by browser SW byte-change detection, not by the cache name. +func swCacheVersion(buildVersion string) string { + v := strings.TrimPrefix(buildVersion, "v") + if v == "" { + return "dev" + } + return v +} + +// loadServiceWorker reads sw.js from the given embedded FS, validates the +// __MARKGO_CACHE_VERSION__ placeholder is present, and returns the bytes +// with the placeholder substituted to version. Returns a startup-time +// error if sw.js is missing or the placeholder is absent — both are +// build invariants and should never be observed at runtime. +// +// The returned modTime is time.Now() at substitution; SW scripts are +// fetched no-cache by browsers, so Last-Modified is not load-bearing. +func loadServiceWorker(embedded fs.FS, version string) ([]byte, time.Time, error) { + raw, err := fs.ReadFile(embedded, "sw.js") + if err != nil { + return nil, time.Time{}, fmt.Errorf("read embedded sw.js: %w", err) + } + if !bytes.Contains(raw, []byte(cacheVersionPlaceholder)) { + return nil, time.Time{}, fmt.Errorf("embedded sw.js missing %s placeholder", cacheVersionPlaceholder) + } + substituted := bytes.ReplaceAll(raw, []byte(cacheVersionPlaceholder), []byte(version)) + return substituted, time.Now(), nil +} diff --git a/internal/commands/serve/sw_test.go b/internal/commands/serve/sw_test.go new file mode 100644 index 0000000..5c7276b --- /dev/null +++ b/internal/commands/serve/sw_test.go @@ -0,0 +1,161 @@ +package serve + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/gin-gonic/gin" +) + +// TestSwCacheVersion: the three cases that determine cache-name shape — +// stamped build, "dev" (or empty) build, and leading-v strip. +func TestSwCacheVersion(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"semver with leading v", "v3.17.0", "3.17.0"}, + {"semver without leading v", "3.17.0", "3.17.0"}, + {"empty falls back to dev", "", "dev"}, + {"literal v alone strips to empty then dev", "v", "dev"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := swCacheVersion(tc.in); got != tc.want { + t.Errorf("swCacheVersion(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestLoadServiceWorker_SubstitutesPlaceholder: the happy path — embedded +// sw.js has the placeholder, it gets substituted to the version string. +func TestLoadServiceWorker_SubstitutesPlaceholder(t *testing.T) { + embedded := fstest.MapFS{ + "sw.js": &fstest.MapFile{Data: []byte("const CACHE_VERSION = '__MARKGO_CACHE_VERSION__';")}, + } + body, _, err := loadServiceWorker(embedded, "3.17.0") + if err != nil { + t.Fatalf("loadServiceWorker: %v", err) + } + got := string(body) + if !strings.Contains(got, "const CACHE_VERSION = '3.17.0';") { + t.Errorf("body did not contain substituted version: %q", got) + } + if strings.Contains(got, "__MARKGO_CACHE_VERSION__") { + t.Errorf("placeholder still present after substitution: %q", got) + } +} + +// TestLoadServiceWorker_FailsLoudOnMissingPlaceholder: build invariant — +// if a future templates refactor drops the placeholder, startup fails +// rather than silently shipping un-versioned cache names. +func TestLoadServiceWorker_FailsLoudOnMissingPlaceholder(t *testing.T) { + embedded := fstest.MapFS{ + "sw.js": &fstest.MapFile{Data: []byte("const CACHE_VERSION = 7;")}, + } + _, _, err := loadServiceWorker(embedded, "3.17.0") + if err == nil { + t.Fatal("loadServiceWorker: want error for missing placeholder, got nil") + } + if !strings.Contains(err.Error(), "__MARKGO_CACHE_VERSION__") { + t.Errorf("error %q should mention placeholder name", err) + } +} + +// TestLoadServiceWorker_FailsOnMissingFile: embedded read failure is a +// startup-only error condition (build invariant). +func TestLoadServiceWorker_FailsOnMissingFile(t *testing.T) { + embedded := fstest.MapFS{} + _, _, err := loadServiceWorker(embedded, "3.17.0") + if err == nil { + t.Fatal("loadServiceWorker: want error for missing sw.js, got nil") + } +} + +// TestServeSwJs_EmbeddedSubstitutesVersion: with no overlay, served body +// is the substituted embedded body. Equivalent to a deployment without +// STATIC_PATH set. +func TestServeSwJs_EmbeddedSubstitutesVersion(t *testing.T) { + substituted := []byte("const CACHE_VERSION = '3.17.0';") + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/sw.js", serveSwJs(nil, substituted, time.Now())) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/sw.js") + if err != nil { + t.Fatalf("GET /sw.js: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if !bytes.Equal(body, substituted) { + t.Errorf("body %q, want %q", string(body), substituted) + } +} + +// TestServeSwJs_DevFallbackInCacheNames: with empty version → cache names +// embed "dev" (via swCacheVersion fallback). Composes the two helpers. +func TestServeSwJs_DevFallbackInCacheNames(t *testing.T) { + embedded := fstest.MapFS{ + "sw.js": &fstest.MapFile{Data: []byte("const CACHE_VERSION = '__MARKGO_CACHE_VERSION__';")}, + } + body, modTime, err := loadServiceWorker(embedded, swCacheVersion("")) + if err != nil { + t.Fatalf("loadServiceWorker: %v", err) + } + if !strings.Contains(string(body), "'dev'") { + t.Errorf("expected 'dev' fallback in body, got %q", string(body)) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/sw.js", serveSwJs(nil, body, modTime)) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/sw.js") + if err != nil { + t.Fatalf("GET /sw.js: %v", err) + } + defer resp.Body.Close() + served, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(served), "'dev'") { + t.Errorf("served body missing 'dev': %q", string(served)) + } +} + +// TestServeSwJs_OverlayBypassesSubstitution: when an operator drops +// /sw.js, raw bytes serve verbatim — no substitution +// attempted on operator-owned content. Operator owns their cache version. +func TestServeSwJs_OverlayBypassesSubstitution(t *testing.T) { + localDir := t.TempDir() + operatorContent := "// Operator's own sw.js with their own CACHE_VERSION = 42" + mustWrite(t, filepath.Join(localDir, "sw.js"), operatorContent) + + substituted := []byte("EMBEDDED-WITH-SUBSTITUTION-MUST-NOT-LEAK") + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/sw.js", serveSwJs(http.Dir(localDir), substituted, time.Now())) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/sw.js") + if err != nil { + t.Fatalf("GET /sw.js: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if string(body) != operatorContent { + t.Errorf("operator overlay not honored: body %q, want %q", string(body), operatorContent) + } +} diff --git a/web/static/sw.js b/web/static/sw.js index 0cd2fa8..c4bc6ab 100644 --- a/web/static/sw.js +++ b/web/static/sw.js @@ -8,7 +8,13 @@ * - Network-only: auth routes, compose, admin, feeds, API */ -const CACHE_VERSION = 7; +// CACHE_VERSION is injected at server startup from buildInfo.Version +// (see internal/commands/serve/sw.go). The literal below is a placeholder +// for the embedded copy; the served bytes have it substituted to the +// running build's semver (e.g. "3.17.0") or "dev" for unstamped builds. +// Operators overriding sw.js via STATIC_PATH ship raw bytes — they own +// their cache version and bypass auto-bump. +const CACHE_VERSION = '__MARKGO_CACHE_VERSION__'; const PRECACHE = `markgo-precache-v${CACHE_VERSION}`; const STATIC_CACHE = `markgo-static-v${CACHE_VERSION}`; const CONTENT_CACHE = `markgo-content-v${CACHE_VERSION}`; From 11350fab54ba13333c7189fdbd35ece24ac278cd Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:09:47 +0545 Subject: [PATCH 2/6] feat(a11y): lift --color-primary to AA 4.5:1 across 4 outlier contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #90. Verified WCAG AA across 5 presets × 2 modes (10 contexts); fixed 4 outliers where --color-primary failed 4.5:1 against bg-primary: ocean light 3.68 → 5.36 (cyan-600 → cyan-700) forest light 3.77 → 5.48 (emerald-600 → emerald-700) sunset light 3.56 → 5.18 (orange-600 → orange-700) default dark 3.45 → 7.02 (blue-600 → blue-400 via minimal.css) minimal.css default-dark lift: --theme-primary was set on :root only, so main.css's `var(--theme-primary, #60a5fa)` fallback never triggered in dark mode. Adding it to minimal.css's dark blocks closes the chain. --- web/static/css/main.css | 21 ++++++++++++--------- web/static/css/themes/minimal.css | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/web/static/css/main.css b/web/static/css/main.css index 83eb338..3ef2406 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -1041,26 +1041,29 @@ html { Theme Presets ========================================================================== */ -/* Theme: Ocean */ +/* Theme: Ocean — primary lifted to cyan-700 (was cyan-600) for AA 4.5:1 + on white; primary-dark stepped to cyan-800 to preserve hover contrast. */ [data-color-theme="ocean"] { - --color-primary: #0891b2; - --color-primary-dark: #0e7490; + --color-primary: #0e7490; + --color-primary-dark: #155e75; --color-accent: #22d3ee; --color-success: #14b8a6; } -/* Theme: Forest */ +/* Theme: Forest — primary lifted to emerald-700 (was emerald-600) for AA; + primary-dark stepped to emerald-800. */ [data-color-theme="forest"] { - --color-primary: #059669; - --color-primary-dark: #047857; + --color-primary: #047857; + --color-primary-dark: #065f46; --color-accent: #34d399; --color-success: #34d399; } -/* Theme: Sunset */ +/* Theme: Sunset — primary lifted to orange-700 (was orange-600) for AA; + primary-dark stepped to orange-800. */ [data-color-theme="sunset"] { - --color-primary: #ea580c; - --color-primary-dark: #c2410c; + --color-primary: #c2410c; + --color-primary-dark: #9a3412; --color-accent: #fb923c; --color-success: #10b981; } diff --git a/web/static/css/themes/minimal.css b/web/static/css/themes/minimal.css index 5b9563c..8d8c2cf 100644 --- a/web/static/css/themes/minimal.css +++ b/web/static/css/themes/minimal.css @@ -52,7 +52,11 @@ "Source Code Pro", monospace; } -/* Dark mode — system preference */ +/* Dark mode — system preference. + --theme-primary lifted from blue-600 (#2563eb, 3.45:1 on #0f172a — AA fail) + to blue-400 (#60a5fa, 7.2:1 — AA pass). Without this override main.css's + `var(--theme-primary, #60a5fa)` fallback chain inherits the light-mode + blue-600 above and the dark-mode lift never triggers. */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --theme-text-primary: #f9fafb; @@ -64,17 +68,19 @@ --theme-border: #334155; --theme-border-light: #475569; --theme-border-dark: #64748b; + --theme-primary: #60a5fa; + --theme-primary-dark: #93c5fd; --theme-success-bg: #1a3a2a; --theme-success-text: #86efac; --theme-success-border: #2d5a3e; --theme-error-bg: #3b1c22; --theme-error-text: #fca5a5; --theme-error-border: #5a2d35; - --theme-focus-ring: rgba(37, 99, 235, 0.25); + --theme-focus-ring: rgba(96, 165, 250, 0.25); } } -/* Dark mode — manual toggle */ +/* Dark mode — manual toggle. Same --theme-primary lift as the @media block. */ [data-theme="dark"] { --theme-text-primary: #f9fafb; --theme-text-secondary: #d1d5db; @@ -85,11 +91,13 @@ --theme-border: #334155; --theme-border-light: #475569; --theme-border-dark: #64748b; + --theme-primary: #60a5fa; + --theme-primary-dark: #93c5fd; --theme-success-bg: #1a3a2a; --theme-success-text: #86efac; --theme-success-border: #2d5a3e; --theme-error-bg: #3b1c22; --theme-error-text: #fca5a5; --theme-error-border: #5a2d35; - --theme-focus-ring: rgba(37, 99, 235, 0.25); + --theme-focus-ring: rgba(96, 165, 250, 0.25); } From 009f451a5118ddc3cd08668e67593cdd17a0655c Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:11:32 +0545 Subject: [PATCH 3/6] refactor(web): replace prependCard innerHTML with createElement chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #85. Two innerHTML string-concat assignments → inline DOM construction with textContent for user content. Hygiene swap, not a security fix — the prior code escaped via a helper. Removing escapeHTML along with its last callers (no dead code). --- web/static/js/modules/compose-sheet.js | 74 +++++++++++++++++--------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/web/static/js/modules/compose-sheet.js b/web/static/js/modules/compose-sheet.js index 994bd6f..ca7bc28 100644 --- a/web/static/js/modules/compose-sheet.js +++ b/web/static/js/modules/compose-sheet.js @@ -355,12 +355,6 @@ function handleKeydown(e) { } } -function escapeHTML(str) { - const el = document.createElement('span'); - el.textContent = str; - return el.innerHTML; -} - function prependCard(data, content, title) { // Only update if we're on the feed page if (document.body.dataset.template !== 'feed') return; @@ -373,29 +367,57 @@ function prependCard(data, content, title) { if (data.type === 'thought') { card.className = 'feed-card feed-card-thought'; - card.innerHTML = - '
' + - '
' + - '
' + - '

' + escapeHTML(content) + '

' + - '
' + - '
' + - '' + - '
' + - '
'; + + const accent = document.createElement('div'); + accent.className = 'feed-card-accent'; + + const body = document.createElement('div'); + body.className = 'feed-card-body'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'feed-card-content thought-content'; + const p = document.createElement('p'); + p.textContent = content; + contentDiv.appendChild(p); + + const meta = document.createElement('div'); + meta.className = 'feed-card-meta'; + const time = document.createElement('time'); + time.className = 'feed-card-time'; + time.dateTime = now; + time.textContent = 'just now'; + meta.appendChild(time); + + body.append(contentDiv, meta); + card.append(accent, body); } else { // Article or link — card with title card.className = 'feed-card'; - card.innerHTML = - '
' + - '

' + - '' + escapeHTML(title || 'Untitled') + '' + - '

' + - '

' + escapeHTML(content.substring(0, 160)) + '

' + - '
' + - '' + - '
' + - '
'; + + const body = document.createElement('div'); + body.className = 'feed-card-body'; + + const h3 = document.createElement('h3'); + h3.className = 'feed-card-title'; + const a = document.createElement('a'); + a.href = data.url; + a.textContent = title || 'Untitled'; + h3.appendChild(a); + + const excerpt = document.createElement('p'); + excerpt.className = 'feed-card-excerpt'; + excerpt.textContent = content.substring(0, 160); + + const meta = document.createElement('div'); + meta.className = 'feed-card-meta'; + const time = document.createElement('time'); + time.className = 'feed-card-time'; + time.dateTime = now; + time.textContent = 'just now'; + meta.appendChild(time); + + body.append(h3, excerpt, meta); + card.appendChild(body); } // Animate entrance From 9762a305fa6587afbd801af62e53aefb321e8237 Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:12:08 +0545 Subject: [PATCH 4/6] chore(web): delete vestigial articles.css and msapplication-navbutton-color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #86, #93. articles.css was a 5-line header-comment-only file referenced from base.html — layout for /writing/ comes from components.css. msapplication-navbutton-color is an IE/Edge legacy meta tag (MDN deprecated; no modern browser honors it). Audit-doc claim of stale vnykmshr.github.io URLs in Apple favicons was verified false — zero hits across the repo. #93 narrows to the 1-line legacy-meta drop. --- web/static/css/articles.css | 5 ----- web/templates/base.html | 2 -- 2 files changed, 7 deletions(-) delete mode 100644 web/static/css/articles.css diff --git a/web/static/css/articles.css b/web/static/css/articles.css deleted file mode 100644 index 06712cd..0000000 --- a/web/static/css/articles.css +++ /dev/null @@ -1,5 +0,0 @@ -/* ========================================================================== - Articles (Writing) Page - Uses shared .content-stream from components.css for card layout. - Page-specific overrides only. - ========================================================================== */ diff --git a/web/templates/base.html b/web/templates/base.html index 10f5119..afb96a8 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -95,7 +95,6 @@ - {{ end }} @@ -158,7 +157,6 @@ - From ac3c287febc8ea3ddaf5ed63634e86c233e49d1a Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:12:47 +0545 Subject: [PATCH 5/6] docs(changelog): v3.17.0 release notes --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ff55f..f96d58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.17.0] - 2026-05-18 + +Theme: **dark-mode contrast verification + SW cache auto-bump.** A WCAG +AA sweep across 5 color presets × 2 modes (light/dark) found 4 link-color +contexts failing the 4.5:1 ratio; all lifted to passing. Service-worker +cache version is now auto-injected from the build version at startup — +operators no longer need to remember to bump it on each release. + +### Added + +- **WCAG AA verification across all theme contexts.** `--color-primary` + lifted in 4 contexts: ocean light (3.68 → 5.36), forest light + (3.77 → 5.48), sunset light (3.56 → 5.18), default dark (3.45 → 7.02). + The default-dark fix lives in `themes/minimal.css` — the lift was + declared in main.css's dark block but `var(--theme-primary, #60a5fa)` + never triggered because minimal.css set `--theme-primary` on `:root` + unconditionally. +- **Auto-injected SW `CACHE_VERSION`.** Embedded `sw.js` ships a + placeholder substituted at server startup from the running build + version (`strings.TrimPrefix(constants.AppVersion, "v")`, `"dev"` + fallback for unstamped builds). Operator-supplied + `/sw.js` serves raw — operator owns their cache version + and bypasses the auto-bump. Documented in `docs/configuration.md`. + +### Removed + +- **`web/static/css/articles.css`** — 5-line header-comment-only file; + layout came from `components.css` all along. +- **`msapplication-navbutton-color`** meta tag — IE/Edge legacy, MDN + deprecated, no modern browser honors it. + +### Changed + +- **`prependCard` in `compose-sheet.js`** now constructs DOM via + `createElement` + `textContent` instead of `innerHTML` string + concatenation. Behavior unchanged; the prior code escaped via a + helper. Hygiene swap for grep-ability, not a security fix. + ## [3.16.0] - 2026-05-18 Theme: **security headers + demo retire.** First user-driven scoping pass — From 897d34f029e26054672aecba593b745556acb448 Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Mon, 18 May 2026 22:26:33 +0545 Subject: [PATCH 6/6] fix(serve): emit slog.Warn on sw.js overlay misconfig instead of silent fallback Mirrors overlayFS.Open's contract (overlay.go:41-46) and CLAUDE.md brand-logo template-overlay pattern. ENOENT stays silent (operator chose not to override); non-ENOENT errors and IsDir misconfig now emit slog.Warn before falling through to embedded. Regression test asserts the warning fires. --- internal/commands/serve/command.go | 2 +- internal/commands/serve/overlay.go | 29 ++++++--- internal/commands/serve/overlay_test.go | 4 +- internal/commands/serve/sw_test.go | 78 ++++++++++++++++++++++++- 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/internal/commands/serve/command.go b/internal/commands/serve/command.go index add5c63..d5018e9 100644 --- a/internal/commands/serve/command.go +++ b/internal/commands/serve/command.go @@ -363,7 +363,7 @@ func setupRoutes(router *gin.Engine, h *handlers.Router, sessionStore *middlewar if dirExists(cfg.StaticPath) { swLocalFS = http.Dir(cfg.StaticPath) } - registerGET(router, "/sw.js", serveSwJs(swLocalFS, swBody, swModTime)) + registerGET(router, "/sw.js", serveSwJs(swLocalFS, swBody, swModTime, logger)) // Uploaded assets — filesystem only, never embedded if cfg.Upload.Path != "" { if err := os.MkdirAll(cfg.Upload.Path, 0o755); err != nil { //nolint:gosec // upload dir needs to be accessible diff --git a/internal/commands/serve/overlay.go b/internal/commands/serve/overlay.go index e2cca65..8d7f52c 100644 --- a/internal/commands/serve/overlay.go +++ b/internal/commands/serve/overlay.go @@ -57,20 +57,33 @@ func (o *overlayFS) Open(name string) (http.File, error) { // owns their cache version). This is intentional; documented in // docs/configuration.md. // -// Explicit directory rejection on the local path mirrors the prior shape: -// c.FileFromFS routes through http.FileServer which redirects on -// directories, and gin's RedirectTrailingSlash would loop that back. The -// /static/* mount avoids this because gin's createStaticHandler has an -// explicit *OnlyFilesFS type-check; the /sw.js route bypasses that path. -func serveSwJs(localFS http.FileSystem, substitutedBody []byte, modTime time.Time) func(c *gin.Context) { +// Failure-mode mirror with overlayFS.Open: ENOENT is silent (operator chose +// not to override), but non-ENOENT errors and operator misconfig (e.g. a +// directory at the overlay path) emit slog.Warn before fall-through so the +// regression isn't silent. The IsDir guard also avoids the directory +// redirect loop that c.FileFromFS / http.FileServer would trigger via +// gin's RedirectTrailingSlash. +func serveSwJs(localFS http.FileSystem, substitutedBody []byte, modTime time.Time, logger *slog.Logger) func(c *gin.Context) { return func(c *gin.Context) { if localFS != nil { - if f, err := localFS.Open("sw.js"); err == nil { + f, err := localFS.Open("sw.js") + switch { + case err == nil: defer f.Close() - if stat, sErr := f.Stat(); sErr == nil && !stat.IsDir() { + stat, sErr := f.Stat() + switch { + case sErr != nil: + logger.Warn("sw.js overlay stat failed; falling back to embedded", "error", sErr) + case stat.IsDir(): + logger.Warn("sw.js overlay path is a directory; falling back to embedded") + default: http.ServeContent(c.Writer, c.Request, "sw.js", stat.ModTime(), f) return } + case errors.Is(err, fs.ErrNotExist): + // Operator chose not to override — silent fall-through. + default: + logger.Warn("sw.js overlay open failed; falling back to embedded", "error", err) } } http.ServeContent(c.Writer, c.Request, "sw.js", modTime, bytes.NewReader(substitutedBody)) diff --git a/internal/commands/serve/overlay_test.go b/internal/commands/serve/overlay_test.go index f474f2d..d4dadc2 100644 --- a/internal/commands/serve/overlay_test.go +++ b/internal/commands/serve/overlay_test.go @@ -128,7 +128,7 @@ func TestStaticMount_OverlayIntegration(t *testing.T) { r.StaticFS("/static", &gin.OnlyFilesFS{FileSystem: overlay}) // New signature: localFS for raw-passthrough, substituted body fallback. // Test substituted body uses literal "EMBEDDED-SW" so existing assertions hold. - swHandler := serveSwJs(http.Dir(localDir), []byte("EMBEDDED-SW"), time.Now()) + swHandler := serveSwJs(http.Dir(localDir), []byte("EMBEDDED-SW"), time.Now(), discardLogger()) r.GET("/sw.js", swHandler) r.HEAD("/sw.js", swHandler) @@ -249,7 +249,7 @@ func TestStaticMount_SwJsDirectoryFallsBackToEmbedded(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.GET("/sw.js", serveSwJs(http.Dir(localDir), []byte("EMBEDDED-FALLBACK"), time.Now())) + r.GET("/sw.js", serveSwJs(http.Dir(localDir), []byte("EMBEDDED-FALLBACK"), time.Now(), discardLogger())) srv := httptest.NewServer(r) defer srv.Close() diff --git a/internal/commands/serve/sw_test.go b/internal/commands/serve/sw_test.go index 5c7276b..9b50121 100644 --- a/internal/commands/serve/sw_test.go +++ b/internal/commands/serve/sw_test.go @@ -2,11 +2,15 @@ package serve import ( "bytes" + "context" "io" + "log/slog" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" + "sync" "testing" "testing/fstest" "time" @@ -14,6 +18,38 @@ import ( "github.com/gin-gonic/gin" ) +// captureHandler is a slog.Handler that records each entry's level + message +// for assertions. Race-safe so it works under t.Parallel and httptest. +type captureHandler struct { + mu sync.Mutex + records []slog.Record +} + +func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } + +// Handle takes slog.Record by value per the slog.Handler interface contract; +// the heavyParam lint warning here is a false positive for an interface impl. +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { //nolint:gocritic // slog.Handler interface + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r) + return nil +} +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func (h *captureHandler) warnings() []string { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, 0, len(h.records)) + for i := range h.records { + if h.records[i].Level == slog.LevelWarn { + out = append(out, h.records[i].Message) + } + } + return out +} + // TestSwCacheVersion: the three cases that determine cache-name shape — // stamped build, "dev" (or empty) build, and leading-v strip. func TestSwCacheVersion(t *testing.T) { @@ -88,7 +124,7 @@ func TestServeSwJs_EmbeddedSubstitutesVersion(t *testing.T) { substituted := []byte("const CACHE_VERSION = '3.17.0';") gin.SetMode(gin.TestMode) r := gin.New() - r.GET("/sw.js", serveSwJs(nil, substituted, time.Now())) + r.GET("/sw.js", serveSwJs(nil, substituted, time.Now(), discardLogger())) srv := httptest.NewServer(r) defer srv.Close() @@ -119,7 +155,7 @@ func TestServeSwJs_DevFallbackInCacheNames(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.GET("/sw.js", serveSwJs(nil, body, modTime)) + r.GET("/sw.js", serveSwJs(nil, body, modTime, discardLogger())) srv := httptest.NewServer(r) defer srv.Close() @@ -134,6 +170,42 @@ func TestServeSwJs_DevFallbackInCacheNames(t *testing.T) { } } +// TestServeSwJs_OverlayDirectoryEmitsWarning: operator misconfig (directory +// at /sw.js) falls back to embedded AND emits slog.Warn so +// the regression isn't silent — mirroring overlayFS.Open's contract at +// lines 41-46 and the brand-logo template-overlay pattern. +func TestServeSwJs_OverlayDirectoryEmitsWarning(t *testing.T) { + localDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(localDir, "sw.js"), 0o755); err != nil { + t.Fatal(err) + } + capture := &captureHandler{} + logger := slog.New(capture) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/sw.js", serveSwJs(http.Dir(localDir), []byte("FALLBACK"), time.Now(), logger)) + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/sw.js") + if err != nil { + t.Fatalf("GET /sw.js: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("status %d, want 200 (falls back to embedded)", resp.StatusCode) + } + + warns := capture.warnings() + if len(warns) == 0 { + t.Fatal("expected slog.Warn on directory overlay; got 0 warnings") + } + if !strings.Contains(warns[0], "directory") { + t.Errorf("warning %q should mention directory", warns[0]) + } +} + // TestServeSwJs_OverlayBypassesSubstitution: when an operator drops // /sw.js, raw bytes serve verbatim — no substitution // attempted on operator-owned content. Operator owns their cache version. @@ -145,7 +217,7 @@ func TestServeSwJs_OverlayBypassesSubstitution(t *testing.T) { substituted := []byte("EMBEDDED-WITH-SUBSTITUTION-MUST-NOT-LEAK") gin.SetMode(gin.TestMode) r := gin.New() - r.GET("/sw.js", serveSwJs(http.Dir(localDir), substituted, time.Now())) + r.GET("/sw.js", serveSwJs(http.Dir(localDir), substituted, time.Now(), discardLogger())) srv := httptest.NewServer(r) defer srv.Close()