feat(server): views.file → form editor on browser navigation

A no-slash GET to a data file, in a directory whose cascade declares
views.file = {tool: form}, now serves the form editor bound to that file
(render-edit; POST goes to the canonical <file>.yaml.html update URL).

Gated on Accept: text/html so it only fires for browser NAVIGATIONS — the
tables client reads rows via fetch() (Accept: */*) and gets raw YAML
unchanged, and ?raw is an explicit bytes escape hatch. A directory without
views.file keeps serving raw bytes. Opt-in per subtree; presentation only
(ACL/WORM stay orthogonal and server-enforced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 10:31:24 -05:00
parent 3e7aa34e49
commit ee371c5bb2
2 changed files with 96 additions and 0 deletions

View file

@ -1236,6 +1236,27 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// views.file → form editor. A browser NAVIGATION (Accept: text/html) to a
// no-slash data file whose cascade declares views.file = {tool: form}
// serves the form editor bound to that file. Programmatic reads — the
// tables client fetches rows with Accept: */* — and an explicit ?raw fall
// through to the raw bytes (the injected-row / ServeFile path below), so
// this never breaks row fetching. The POST goes to the canonical
// <file>.yaml.html update URL (the existing form-update handler).
if r.Method == http.MethodGet && !r.URL.Query().Has("raw") &&
strings.Contains(r.Header.Get("Accept"), "text/html") {
if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" {
fr := &handler.FormRequest{
Kind: "render-edit",
SpecPath: filepath.Join(filepath.Dir(absPath), "form.yaml"),
DataPath: absPath,
SubmitURL: urlPath + ".html",
}
handler.ServeForm(cfg, fr, w, r)
return
}
}
// (MD→{docx,html,pdf} on-demand conversion now lives at // (MD→{docx,html,pdf} on-demand conversion now lives at
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL, // `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
// see RecognizeVirtualConvert). The .md source serves // see RecognizeVirtualConvert). The .md source serves

View file

@ -1057,3 +1057,78 @@ func writeRootBundle(t *testing.T, root string, members map[string]string) {
t.Fatalf("write bundle: %v", err) t.Fatalf("write bundle: %v", err)
} }
} }
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
// cascade declares views.file = {tool: form}, serves the form editor bound to
// that file — while a programmatic fetch (Accept: */*, the tables client) and
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
// without views.file keeps serving raw bytes on navigation too.
func TestDispatchFileToFormView(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
dir := filepath.Join(root, "Proj", "records")
mustMkdir(t, dir)
// views.file declared on the records dir → form editor for its files.
mustWrite(t, filepath.Join(dir, ".zddc"),
"views:\n file:\n tool: form\n")
// Form schema lives in the supporting-files reserve.
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
"schema:\n type: object\n properties:\n title:\n type: string\n")
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
// A sibling file in a dir WITHOUT views.file stays raw.
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// Navigation → form editor HTML.
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
}
if strings.Contains(rec.Body.String(), "title: Hello") {
t.Errorf("navigation served raw YAML, want the form editor")
}
// Programmatic fetch (Accept: */*) → raw YAML bytes.
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
}
// ?raw escape hatch → raw bytes even for a browser.
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec3.Body.String(), "title: Hello") {
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
}
// No views.file declared → navigation still serves raw bytes.
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec4.Body.String(), "x: 1") {
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
}
}