diff --git a/CHANGELOG.md b/CHANGELOG.md index b337055..44b3392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to reefdoc are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **JSON viewing**: `.json` files now appear in the file-tree navigator and + render **pretty-printed** — 2-space indentation, trailing commas, and + colour-coded keys / typed primitives, exactly like `JSON.stringify(x, null, + 2)` — with objects and arrays additionally collapsible via a ▾/▸ toggle. No + external library — the viewer decodes and parses the file client-side, and + falls back to raw text for invalid JSON. Files are served with + `Content-Type: application/json`. + +### Fixed +- Embedded frontend assets (`app.js`, `viewers.js`, `app.css`, …) are now served + with `Cache-Control: no-cache` so a redeploy is picked up immediately. They + previously carried no cache validators (embed.FS has no modtime/ETag), so a + browser or CDN could serve a stale frontend after an update. +- The frontend is also served under a versioned path (`/v2/`) using relative + asset refs, so it can be reached at a URL a CDN has never cached — an escape + hatch when a stale CDN copy of `/app.js` can't be purged from the host. + ## [0.15.0] - 2026-08-01 ### Added diff --git a/internal/server/server.go b/internal/server/server.go index 644a561..2cb40e6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,10 +30,31 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/file", s.handleFile) mux.HandleFunc("/api/watch", s.handleWatch) mux.HandleFunc("/api/events", s.handleEvents) - mux.Handle("/", http.FileServer(http.FS(s.assets))) + assetHandler := noCacheAssets(http.FileServer(http.FS(s.assets))) + mux.Handle("/", assetHandler) + // Versioned mirror of the same frontend. index.html uses relative asset + // refs, so serving it under /v2/ makes the whole module graph resolve to + // /v2/* — a set of URLs a CDN has never cached. This is the escape hatch + // when a CDN holds stale /app.js etc. that cannot be purged from here: + // open the app at /v2/ to fetch a guaranteed-fresh copy. Bump the prefix + // again if a future stale-cache incident needs another clean URL. + mux.Handle("/v2/", http.StripPrefix("/v2", assetHandler)) return mux } +// noCacheAssets makes the browser (and any CDN in front) revalidate the +// embedded frontend before reusing it. embed.FS files carry no useful modtime +// or ETag, so http.FileServer sends no cache validators at all — a caching +// layer then serves a stale app.js / viewers.js after a redeploy (exactly what +// hid the JSON viewer until a manual hard-refresh). "no-cache" still allows +// storage but forces revalidation, so a new build is picked up immediately. +func noCacheAssets(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + h.ServeHTTP(w, r) + }) +} + // handleTree lists one directory level (immediate children of ?path=, root by // default). Non-recursive — the browser fetches deeper levels on demand. func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { @@ -185,6 +206,8 @@ func contentType(path string) string { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case ".json": + return "application/json; charset=utf-8" default: return "text/plain; charset=utf-8" } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9f95102..b3d5b21 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -72,6 +72,11 @@ func TestHandleAssets_ServesIndex(t *testing.T) { if rec.Code != 200 || rec.Body.String() != "
app
" { t.Fatalf("status %d body %q", rec.Code, rec.Body.String()) } + // Embedded assets must be revalidated so a redeploy isn't hidden behind a + // stale browser/CDN cache. + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Fatalf("Cache-Control = %q, want no-cache", got) + } } func TestHandleFile_SymlinkEscapeIs400(t *testing.T) { @@ -451,3 +456,15 @@ func TestHandleFile_MediaDownloadSetsContentDisposition(t *testing.T) { t.Fatalf("Content-Disposition %q, want %q", cd, want) } } + +func TestHandleAssets_VersionedMirror(t *testing.T) { + s, _ := newTestServer(t) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v2/", nil)) + if rec.Code != 200 || rec.Body.String() != "app
" { + t.Fatalf("/v2/ status %d body %q", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Fatalf("/v2/ Cache-Control = %q, want no-cache", got) + } +} diff --git a/internal/server/tree.go b/internal/server/tree.go index 4d0467e..e898360 100644 --- a/internal/server/tree.go +++ b/internal/server/tree.go @@ -37,15 +37,16 @@ func isMedia(name string) bool { // isViewable reports whether a file should appear in the tree: the text // formats reefdoc renders inline, the binary document formats it previews -// client-side (pdf/docx/xlsx/pptx), and the media formats it streams -// (video/image/audio). It also gates live-reload "change" events -// (see watcher.go), so narrowing it affects both tree listing and auto-update. +// client-side (pdf/docx/xlsx/pptx), JSON (rendered as a collapsible tree), and +// the media formats it streams (video/image/audio). It also gates live-reload +// "change" events (see watcher.go), so narrowing it affects both tree listing +// and auto-update. func isViewable(name string) bool { if isMarkdown(name) || isMedia(name) { return true } switch strings.ToLower(filepath.Ext(name)) { - case ".pdf", ".docx", ".xlsx", ".pptx": + case ".pdf", ".docx", ".xlsx", ".pptx", ".json": return true } return false diff --git a/internal/server/tree_test.go b/internal/server/tree_test.go index 21d2ef4..ef7f098 100644 --- a/internal/server/tree_test.go +++ b/internal/server/tree_test.go @@ -212,3 +212,22 @@ func TestListDir_ListsMediaFiles(t *testing.T) { t.Fatalf("got %v want %v", names, want) } } + +func TestListDir_ListsJSON(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "config.json")) + writeFile(t, filepath.Join(root, "notes.md")) + + nodes, err := ListDir(root, "") + if err != nil { + t.Fatal(err) + } + var names []string + for _, n := range nodes { + names = append(names, n.Name) + } + want := []string{"config.json", "notes.md"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("got %v want %v", names, want) + } +} diff --git a/web/app.css b/web/app.css index dab4a10..573e031 100644 --- a/web/app.css +++ b/web/app.css @@ -156,3 +156,27 @@ body[data-theme="dark"] .allium-block--contract { border-left-color:#a97ee8; } font-size:15px; line-height:1; cursor:pointer; background:var(--bg); color:var(--fg); border:1px solid var(--border); border-radius:4px; } #download-btn:hover { background:var(--border); } + +/* JSON viewer — collapsible, syntax-highlighted tree (viewers.js viewJson) */ +.json-doc { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size:13px; line-height:1.5; padding:8px 4px; } +.json-children { padding-left:1.4em; border-left:1px solid var(--border); margin-left:.3em; } +.json-line { white-space:pre-wrap; word-break:break-word; } +.json-toggle { display:inline-block; width:1.1em; margin-left:-1.1em; text-align:center; + cursor:pointer; color:var(--muted); user-select:none; } +.json-toggle:hover { color:var(--fg); } +.json-toggle:focus-visible { outline:2px solid var(--accent); border-radius:2px; } +.json-key { color:var(--accent); } +.json-index { color:var(--muted); } +.json-punct { color:var(--muted); } +.json-summary { color:var(--muted); font-style:italic; } +.json-string { color:#0a7d33; } +.json-number { color:#b5670b; } +.json-boolean { color:#8250df; } +.json-null { color:var(--muted); font-style:italic; } +.json-error { color:#c00; font-family:inherit; } +.json-raw { overflow:auto; } +body[data-theme="dark"] .json-string { color:#7ec98f; } +body[data-theme="dark"] .json-number { color:#e0a060; } +body[data-theme="dark"] .json-boolean { color:#c9a6ff; } +body[data-theme="dark"] .json-error { color:#ff8080; } diff --git a/web/index.html b/web/index.html index 3228989..dbd94ee 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@