Skip to content
Open
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
}
Expand Down
17 changes: 17 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func TestHandleAssets_ServesIndex(t *testing.T) {
if rec.Code != 200 || rec.Body.String() != "<p>app</p>" {
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) {
Expand Down Expand Up @@ -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() != "<p>app</p>" {
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)
}
}
9 changes: 5 additions & 4 deletions internal/server/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions internal/server/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
24 changes: 24 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
4 changes: 2 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reefdoc</title>
<link rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="./app.css">
<link id="hljs-theme" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css">
<script type="importmap">
Expand Down Expand Up @@ -52,6 +52,6 @@
</div>
<article id="content"><p class="empty">Select a file from the tree.</p></article>
</main>
<script type="module" src="/app.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>
152 changes: 152 additions & 0 deletions web/viewers.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const registry = {
'.docx': viewDocx,
'.xlsx': viewXlsx,
'.pptx': viewPptx,
'.json': viewJson,
};

// getViewer returns the viewer function for a path, or null for text/unknown.
Expand Down Expand Up @@ -187,3 +188,154 @@ async function viewPptx(bytes, container) {
});
ro.observe(container);
}

// --- JSON (pretty, syntax-highlighted, collapsible) ---
// Not a CDN-backed viewer: JSON needs no library. Decodes the raw bytes,
// parses, and builds a collapsible, syntax-coloured tree. Invalid JSON falls
// back to the raw text (best-effort, per the viewer contract at the top).
async function viewJson(bytes, container) {
const doc = container.ownerDocument;
container.innerHTML = '';
const text = new TextDecoder('utf-8').decode(bytes);
const wrap = doc.createElement('div');
wrap.className = 'json-doc';
container.appendChild(wrap);

let data;
try {
data = JSON.parse(text);
} catch (err) {
wrap.className = 'json-doc json-invalid';
const note = doc.createElement('p');
note.className = 'json-error';
note.textContent = 'Invalid JSON (' + err.message + ') — showing raw text.';
const pre = doc.createElement('pre');
pre.className = 'json-raw';
pre.textContent = text;
wrap.appendChild(note);
wrap.appendChild(pre);
return;
}

wrap.appendChild(jsonNode(doc, data, null, true));
}

// jsonText makes a <span class=cls> with textContent s.
function jsonText(doc, s, cls) {
const el = doc.createElement('span');
el.className = cls;
el.textContent = s;
return el;
}

// jsonPrimitiveText maps a JSON primitive to its display class + text. Pure
// (no DOM) so the type/colour mapping is unit-testable on its own.
export function jsonPrimitiveText(value) {
if (value === null) return { cls: 'json-null', text: 'null' };
switch (typeof value) {
case 'string':
// A string with embedded newlines is shown across real lines (the
// container is white-space: pre-wrap) so long multi-line values like a
// message body are readable — quotes/backslashes are still escaped so the
// string stays unambiguous. Single-line strings use JSON.stringify as-is.
if (value.includes('\n')) {
return {
cls: 'json-string json-multiline',
text: '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
};
}
return { cls: 'json-string', text: JSON.stringify(value) };
case 'number': return { cls: 'json-number', text: String(value) };
case 'boolean': return { cls: 'json-boolean', text: String(value) };
default: return { cls: 'json-unknown', text: String(value) };
}
}

// jsonNode builds the DOM for one JSON value, laid out like `JSON.stringify(x,
// null, 2)`: 2-space indentation (via nesting), a "key": prefix for object
// members, no index prefix for array elements, and a trailing comma on every
// item except the last. Objects and arrays are additionally collapsible (a ▾/▸
// toggle folds them to `{…}` / `[…]`); primitives render as a type-coloured
// span. keyLabel is the object key (rendered quoted) or null for array elements
// and the root; isLast omits the trailing comma on the final sibling.
export function jsonNode(doc, value, keyLabel, isLast) {
const node = doc.createElement('div');
node.className = 'json-node';
const head = doc.createElement('div');
head.className = 'json-line';
node.appendChild(head);

const isArr = Array.isArray(value);
const isObj = value !== null && typeof value === 'object';
// Array elements carry no key; object members carry their key string.
const entries = isObj
? (isArr ? value.map((v) => [null, v]) : Object.entries(value))
: [];

const comma = (line) => { if (!isLast) line.appendChild(jsonText(doc, ',', 'json-punct')); };

// Collapse toggle comes first, before the key, for non-empty objects/arrays.
let toggle = null;
if (isObj && entries.length > 0) {
toggle = doc.createElement('span');
toggle.className = 'json-toggle';
toggle.setAttribute('role', 'button');
toggle.tabIndex = 0;
toggle.textContent = '▾';
head.appendChild(toggle);
}

if (keyLabel !== null) {
head.appendChild(jsonText(doc, JSON.stringify(keyLabel), 'json-key'));
head.appendChild(jsonText(doc, ': ', 'json-punct'));
}

if (!isObj) {
const p = jsonPrimitiveText(value);
head.appendChild(jsonText(doc, p.text, p.cls));
comma(head);
return node;
}

const open = isArr ? '[' : '{';
const close = isArr ? ']' : '}';
if (entries.length === 0) {
head.appendChild(jsonText(doc, open + close, 'json-punct'));
comma(head);
return node;
}

head.appendChild(jsonText(doc, open, 'json-punct'));
const summary = jsonText(doc, ' … ' + close, 'json-summary');
summary.hidden = true;
head.appendChild(summary);
const summaryComma = jsonText(doc, ',', 'json-punct');
summaryComma.hidden = true;
if (!isLast) head.appendChild(summaryComma);

const kids = doc.createElement('div');
kids.className = 'json-children';
entries.forEach(([k, v], i) => kids.appendChild(jsonNode(doc, v, k, i === entries.length - 1)));
node.appendChild(kids);

const closer = doc.createElement('div');
closer.className = 'json-line json-closer';
closer.appendChild(jsonText(doc, close, 'json-punct'));
comma(closer);
node.appendChild(closer);

let collapsed = false;
const setCollapsed = (c) => {
collapsed = c;
toggle.textContent = c ? '▸' : '▾';
kids.hidden = c;
closer.hidden = c;
summary.hidden = !c;
summaryComma.hidden = !c;
};
toggle.addEventListener('click', () => setCollapsed(!collapsed));
toggle.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCollapsed(!collapsed); }
});
return node;
}
Loading