feat(zddc-server): include browse/form/tables in apps cascade
Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.
Two coupled fixes:
1. zddc.AppNames had a five-entry list (archive/transmittal/
classifier/mdedit/landing) — predating browse/form/tables.
ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
those three before ever looking at the cascade, falling back to
embedded with an "unknown app" error.
2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
for the HTML directory-listing fallback, bypassing the apps
subsystem entirely. Now takes an optional *apps.Server and
delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
wired, so the cascade is honored at bare directory URLs too
(the most common way browse gets surfaced).
Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.
Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c87fb7f4fa
commit
b1479c5104
5 changed files with 38 additions and 20 deletions
|
|
@ -905,7 +905,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeDirectory(cfg, w, r)
|
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
|
@ -980,7 +980,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeDirectory(cfg, w, r)
|
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,15 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
|
||||||
// ServeDirectory handles a request for a directory path.
|
// ServeDirectory handles a request for a directory path.
|
||||||
// If Accept: application/json → return Caddy-compatible JSON listing.
|
// If Accept: application/json → return Caddy-compatible JSON listing.
|
||||||
// Otherwise → return minimal HTML.
|
// Otherwise → return minimal HTML.
|
||||||
func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
//
|
||||||
|
// appsSrv is optional: when non-nil, HTML responses resolve the
|
||||||
|
// `browse` tool through the apps subsystem so a `.zddc apps:` cascade
|
||||||
|
// entry can override the embedded bytes (handy for live alpha-dev
|
||||||
|
// iteration: point apps.browse: at a path source and every ./build is
|
||||||
|
// served from disk without recompiling the binary). When nil, the
|
||||||
|
// embedded copy is served directly — same behavior as before the
|
||||||
|
// cascade hook was added.
|
||||||
|
func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
|
||||||
urlPath := r.URL.Path
|
urlPath := r.URL.Path
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
|
|
@ -150,12 +158,17 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser HTML fallback: serve the embedded `browse` tool. It's a
|
// Browser HTML fallback: serve the `browse` tool. By default it's
|
||||||
// single-file SPA whose autoDetectServerMode loads the JSON listing
|
// the embedded copy (single-file SPA whose autoDetectServerMode
|
||||||
// for the current directory and renders it as a sortable, filterable
|
// loads the JSON listing for the current directory and renders it
|
||||||
// tree. Same bytes that get served at /<dir>/browse.html — but at
|
// as a sortable, filterable tree). A `.zddc apps: browse:` entry
|
||||||
// the bare directory URL too, so any zddc-served folder presents a
|
// up the chain can override with a path or URL source — when
|
||||||
// usable file browser to anyone who navigates to it.
|
// appsSrv is wired up, delegate to it so cascade entries are
|
||||||
|
// honored at directory URLs too (not just /<dir>/browse.html).
|
||||||
|
if appsSrv != nil {
|
||||||
|
appsSrv.Serve(w, r, "browse", chain, absDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
body := apps.EmbeddedBytes("browse")
|
body := apps.EmbeddedBytes("browse")
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
// Bootstrap state: a fresh build hasn't populated browse.html
|
// Bootstrap state: a fresh build hasn't populated browse.html
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
||||||
// Anonymous: empty email in context.
|
// Anonymous: empty email in context.
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200 (root is public); body = %s",
|
t.Fatalf("status = %d, want 200 (root is public); body = %s",
|
||||||
|
|
@ -70,7 +70,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -100,7 +100,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "admin@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "admin@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("admin status = %d, want 200", rec.Code)
|
t.Fatalf("admin status = %d, want 200", rec.Code)
|
||||||
|
|
@ -120,7 +120,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("private subdir for anonymous: status = %d, want 403", rec.Code)
|
t.Errorf("private subdir for anonymous: status = %d, want 403", rec.Code)
|
||||||
|
|
@ -162,7 +162,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("Accept", "text/html")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -177,7 +177,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -196,7 +196,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("Accept", "text/html")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code == http.StatusFound {
|
if rec.Code == http.StatusFound {
|
||||||
t.Fatalf("got 302 to %q for non-table dir", rec.Header().Get("Location"))
|
t.Fatalf("got 302 to %q for non-table dir", rec.Header().Get("Location"))
|
||||||
|
|
@ -225,7 +225,7 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("Accept", "text/html")
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeDirectory(cfg, rec, req)
|
ServeDirectory(cfg, nil, rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 17:07:33 · c87fb7f-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ import (
|
||||||
// AppNames is the canonical set of app HTML files the server resolves
|
// AppNames is the canonical set of app HTML files the server resolves
|
||||||
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||||
// admin-UI rendering.
|
// admin-UI rendering.
|
||||||
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing"}
|
//
|
||||||
|
// All eight HTML tools belong here — including browse, form, and tables.
|
||||||
|
// Omitting any of them means the apps cascade (.zddc apps:) silently
|
||||||
|
// short-circuits to embedded for that name, defeating live-dev
|
||||||
|
// path-source overrides.
|
||||||
|
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"}
|
||||||
|
|
||||||
// AppsDefaultKey is the special apps-map key that provides the baseline
|
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||||
|
|
@ -232,7 +237,7 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
if !IsValidAppsKey(app) {
|
if !IsValidAppsKey(app) {
|
||||||
errs = append(errs, FieldError{
|
errs = append(errs, FieldError{
|
||||||
Field: fmt.Sprintf("apps.%s", app),
|
Field: fmt.Sprintf("apps.%s", app),
|
||||||
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing)", app),
|
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue