Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<STATIC_PATH>/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 —
Expand Down
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ Drop your SVG at `<STATIC_PATH>/img/brand-logo.svg`. markgo reads it at startup
| `<STATIC_PATH>/img/og-article-default.png` | Per-article OG fallback |
| `<STATIC_PATH>/fonts/...` | Custom web fonts (referenced via `@font-face` in CSS) |
| `<STATIC_PATH>/css/<BLOG_STYLE>.css` | Custom theme stylesheet (any `BLOG_STYLE` name accepted) |
| `<STATIC_PATH>/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+)

Expand Down
19 changes: 15 additions & 4 deletions internal/commands/serve/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <STATIC_PATH>/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, 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
Expand Down
57 changes: 40 additions & 17 deletions internal/commands/serve/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
package serve

import (
"bytes"
"errors"
"io/fs"
"log/slog"
"net/http"
"time"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -45,24 +47,45 @@ 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
// <STATIC_PATH>/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.
//
// 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) {
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 {
f, err := localFS.Open("sw.js")
switch {
case err == nil:
defer f.Close()
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", stat.ModTime(), f)
http.ServeContent(c.Writer, c.Request, "sw.js", modTime, bytes.NewReader(substitutedBody))
}
}
28 changes: 17 additions & 11 deletions internal/commands/serve/overlay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"testing"
"testing/fstest"
"time"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -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(), discardLogger())
r.GET("/sw.js", swHandler)
r.HEAD("/sw.js", swHandler)

Expand Down Expand Up @@ -233,21 +236,20 @@ func TestStaticMount_NoDirectoryListings_BothBranches(t *testing.T) {
})
}

// TestStaticMount_SwJsRejectsDirectory: if an operator misconfigures
// <STATIC_PATH>/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 <STATIC_PATH>/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(), discardLogger()))

srv := httptest.NewServer(r)
defer srv.Close()
Expand All @@ -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 <STATIC_PATH>/sw.js): status %d, want 404", resp.StatusCode)
if resp.StatusCode != 200 {
t.Errorf("GET /sw.js (dir at <STATIC_PATH>/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))
}
}

Expand Down
56 changes: 56 additions & 0 deletions internal/commands/serve/sw.go
Original file line number Diff line number Diff line change
@@ -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 <STATIC_PATH>/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
}
Loading