diff --git a/tables/js/context.js b/tables/js/context.js index ea0a594..bf6cab0 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -22,13 +22,19 @@ // inline context (tests) or open the page through zddc-server. async function load() { const inline = readInlineContext(); - if (inline && Object.keys(inline).length > 0) { + // A fully pre-assembled context (columns + rows) is used as-is — the + // test seam, or any host that renders the whole table server-side. + if (inline && Array.isArray(inline.columns)) { return inline; } + // Otherwise the inline context may still carry the server-injected + // SPEC ({spec, rowSchema}) sourced from /.zddc.d/ — pass it to + // walkServer, which uses it instead of fetching the spec and still + // walks the directory for row files. if (typeof location !== 'undefined' && (location.protocol === 'http:' || location.protocol === 'https:')) { try { - const walked = await walkServer(); + const walked = await walkServer(inline || {}); if (walked) { return walked; } @@ -60,7 +66,8 @@ el.hidden = false; } - async function walkServer() { + async function walkServer(injected) { + injected = injected || {}; const source = window.zddc && window.zddc.source; if (!source) { throw new Error('zddc.source not available'); @@ -77,27 +84,32 @@ } const dir = probe.handle; - // Spec lives at /table.yaml — the page URL is - // /table.html, so the spec is right next door. - const spec = await readYaml(dir, 'table.yaml'); + // Spec: prefer the server-injected #table-context.spec (sourced from + // /.zddc.d/table.yaml). Falling back, read the spec from the + // supporting-files reserve, then the legacy directory root — the + // FS-Access path, where there's no server to inject. + let spec = (injected.spec && Array.isArray(injected.spec.columns)) + ? injected.spec : null; + if (!spec) { + spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']); + } if (!spec || !Array.isArray(spec.columns)) { throw new Error('Spec table.yaml missing columns[]'); } - // Optional row schema from /form.yaml — same JSON Schema - // the form-mode renderer uses. Phase 2 derives per-cell editor - // widgets from it (text/number/date/select/checkbox). - // Best-effort: a directory with only table.yaml still renders - // as a sortable/filterable table; cells fall back to plain - // text inputs without per-property hints. - let rowSchema = null; - try { - const formSpec = await readYaml(dir, 'form.yaml'); - if (formSpec && formSpec.schema) { - rowSchema = formSpec.schema; + // Row schema: prefer the injected #table-context.rowSchema, else read + // /.zddc.d/form.yaml (then legacy root). Best-effort — a table + // with no row schema still renders with plain-text cells. + let rowSchema = injected.rowSchema || null; + if (!rowSchema) { + try { + const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']); + if (formSpec && formSpec.schema) { + rowSchema = formSpec.schema; + } + } catch (_) { + // form.yaml missing or unreadable; carry on without it. } - } catch (_) { - // form.yaml missing or unreadable; carry on without it. } // Rows are every *.yaml in EXCEPT the spec @@ -156,6 +168,22 @@ return window.jsyaml.load(text); } + // readYamlFirst tries each relPath in order, returning the first that + // resolves + parses. Used to read a spec from the supporting-files + // reserve (.zddc.d/) with a fallback to the legacy directory root. + async function readYamlFirst(dir, relPaths) { + let lastErr = null; + for (var i = 0; i < relPaths.length; i++) { + try { + return await readYaml(dir, relPaths[i]); + } catch (err) { + lastErr = err; + } + } + if (lastErr) throw lastErr; + return null; + } + // Walk a "/"-separated relative path under dir, returning the // FileSystemFileHandle (or HttpFileHandle) at the leaf. async function resolveFile(dir, relPath) { diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index 81729b1..d8f155c 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -159,6 +159,10 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { // any of the default-spec virtual-fallback shapes (per-party // mdl/rsk, per-party SSR schema, project-level virtual specs). specEligible := func(specAbs string) bool { + dir, base := filepath.Split(specAbs) + if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) { + return true + } if fileExists(specAbs) { return true } @@ -542,13 +546,19 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter, // --- Helpers ----------------------------------------------------------------- func loadFormSpec(fsRoot, path string) (*FormSpec, error) { - data, err := os.ReadFile(path) + // Prefer the supporting-files reserve: a spec at /.zddc.d/form.yaml + // takes precedence over the legacy /form.yaml. `path` is the legacy + // /form.yaml location the callers build. + dir, base := filepath.Split(path) + data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) if err != nil { - // Default-spec virtual fallback: when no operator file exists at - // path, serve the embedded default if path matches one of the - // recognized virtual fallback shapes (per-party mdl/rsk, per- - // party SSR schema, project-level virtual specs). Mirrors the - // static-handler fallback for direct YAML fetches. + data, err = os.ReadFile(path) + } + if err != nil { + // Default-spec virtual fallback: when no operator file exists in + // either location, serve the embedded default if path matches one of + // the recognized virtual fallback shapes (per-party mdl/rsk, per- + // party SSR schema, project-level virtual specs). if os.IsNotExist(err) { if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok { data = bytes diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 51c6afb..e29a8bb 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -28,11 +28,15 @@ package handler import ( _ "embed" + "encoding/json" "log/slog" "net/http" + "os" "path/filepath" "strings" + "gopkg.in/yaml.v3" + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" @@ -161,6 +165,17 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) { // not name one of the recognized virtual fallback files. func classifyDefaultSpec(rel string) []byte { parts := strings.Split(rel, "/") + // A spec may live either in the directory root (/table.yaml) or in + // the supporting-files reserve (/.zddc.d/table.yaml). Strip a + // ".zddc.d" segment so both classify by the same dir shape. + clean := parts[:0:0] + for _, p := range parts { + if strings.EqualFold(p, ".zddc.d") { + continue + } + clean = append(clean, p) + } + parts = clean switch len(parts) { case 4: // /// — per-party register specs @@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { specAbs := filepath.Join(dirAbs, "table.yaml") - // Presence-based discovery: /table.yaml on disk. - if fileExists(specAbs) { + // Presence-based discovery: the spec in the supporting-files reserve + // (/.zddc.d/table.yaml) or, legacy, the directory root. + if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) { return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} } @@ -362,10 +378,77 @@ func isNotExistError(err error) bool { return err != nil && strings.Contains(err.Error(), "no such file or directory") } -// ServeTable serves the static tables.html bytes for a recognized -// request. ACL gate is the read action at the request directory; on -// allow, the embedded HTML is written verbatim. The client takes over -// from there — see tables/js/main.js. +// LoadViewSpec resolves a config file's bytes for dir, preferring the +// supporting-files reserve /.zddc.d/, then the legacy /, +// then the embedded default for this dir's shape. Returns nil when none +// applies. This is the single seam that puts table/form specs under .zddc.d/ +// (where they're admin-gated + hidden) while staying back-compatible. +func LoadViewSpec(fsRoot, dir, name string) []byte { + if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil { + return b + } + if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil { + return b + } + if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil { + if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil { + return b + } + } + return nil +} + +// injectTableContext writes the resolved table spec + row-form schema into the +// `#table-context` placeholder so the client reads them instead of fetching +// /table.yaml and /form.yaml over HTTP (impossible once the specs +// live under the admin-gated .zddc.d/). The client still walks the directory +// for ROW files — only the SPEC is injected. Shape: +// +// { "spec": , "rowSchema": } +// +// Empty {} when neither resolves (the client then walks for the spec too, +// preserving legacy behavior). Returns an error only if the placeholder is +// absent from the template. +func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) { + ctx := map[string]interface{}{} + if len(tableYAML) > 0 { + var spec interface{} + if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil { + ctx["spec"] = spec + } + } + if len(formYAML) > 0 { + var fs map[string]interface{} + if err := yaml.Unmarshal(formYAML, &fs); err == nil { + if sch, ok := fs["schema"]; ok { + ctx["rowSchema"] = sch + } + } + } + js, err := json.Marshal(ctx) + if err != nil { + return nil, err + } + js = []byte(strings.ReplaceAll(string(js), "{}`) + if !bytesContains(template, needle) { + return nil, errBundle("#table-context placeholder not found in template") + } + replacement := append([]byte(``)...) + return bytesReplace(template, needle, replacement), nil +} + +type errBundle string + +func (e errBundle) Error() string { return string(e) } + +// ServeTable serves the tables HTML for a recognized request, ACL-gated on +// read at the request directory. The resolved table.yaml + form.yaml (from +// .zddc.d/, legacy root, or the embedded default) are injected as +// #table-context so the client never fetches the spec over HTTP. If the +// template predates the placeholder, the bare HTML is served (the client +// falls back to fetching) — keeps this non-breaking before ./build. func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) { p := PrincipalFromContext(r) decider := DeciderFromContext(r) @@ -384,7 +467,14 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r * return } + body := embeddedTablesHTML + tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml") + formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml") + if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil { + body = injected + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(embeddedTablesHTML) + _, _ = w.Write(body) } diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index 24afa84..e369acb 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -184,8 +184,9 @@ func TestRecognizeTableRequest(t *testing.T) { } // TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the -// embedded tables.html bytes verbatim, with the empty inline context -// placeholder intact (so the client knows to walk the directory). +// embedded tables.html with the resolved table spec server-injected into +// #table-context (the embedded default for this virtual MDL dir), so the +// client renders without a separate spec fetch. func TestServeTable_ServesEmbeddedHTML(t *testing.T) { rows := map[string]string{ "D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n", @@ -202,8 +203,13 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) { if !strings.Contains(body, `{}`) { - t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk") + // #table-context is no longer the empty placeholder — the resolved spec + // is injected (the client uses it instead of fetching table.yaml). + if strings.Contains(body, ``) { + t.Error("#table-context still empty; expected the resolved spec to be injected") + } + if !strings.Contains(body, `id="table-context"`) || !strings.Contains(body, `"spec"`) { + t.Error("expected the resolved table spec injected into #table-context") } } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 6491caf..2772057 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1534,7 +1534,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-dev · 2026-06-04 15:19:25 · 45af24b-dirty
@@ -3691,13 +3691,19 @@ body.is-elevated::after { // inline context (tests) or open the page through zddc-server. async function load() { const inline = readInlineContext(); - if (inline && Object.keys(inline).length > 0) { + // A fully pre-assembled context (columns + rows) is used as-is — the + // test seam, or any host that renders the whole table server-side. + if (inline && Array.isArray(inline.columns)) { return inline; } + // Otherwise the inline context may still carry the server-injected + // SPEC ({spec, rowSchema}) sourced from /.zddc.d/ — pass it to + // walkServer, which uses it instead of fetching the spec and still + // walks the directory for row files. if (typeof location !== 'undefined' && (location.protocol === 'http:' || location.protocol === 'https:')) { try { - const walked = await walkServer(); + const walked = await walkServer(inline || {}); if (walked) { return walked; } @@ -3729,7 +3735,8 @@ body.is-elevated::after { el.hidden = false; } - async function walkServer() { + async function walkServer(injected) { + injected = injected || {}; const source = window.zddc && window.zddc.source; if (!source) { throw new Error('zddc.source not available'); @@ -3746,27 +3753,32 @@ body.is-elevated::after { } const dir = probe.handle; - // Spec lives at /table.yaml — the page URL is - // /table.html, so the spec is right next door. - const spec = await readYaml(dir, 'table.yaml'); + // Spec: prefer the server-injected #table-context.spec (sourced from + // /.zddc.d/table.yaml). Falling back, read the spec from the + // supporting-files reserve, then the legacy directory root — the + // FS-Access path, where there's no server to inject. + let spec = (injected.spec && Array.isArray(injected.spec.columns)) + ? injected.spec : null; + if (!spec) { + spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']); + } if (!spec || !Array.isArray(spec.columns)) { throw new Error('Spec table.yaml missing columns[]'); } - // Optional row schema from /form.yaml — same JSON Schema - // the form-mode renderer uses. Phase 2 derives per-cell editor - // widgets from it (text/number/date/select/checkbox). - // Best-effort: a directory with only table.yaml still renders - // as a sortable/filterable table; cells fall back to plain - // text inputs without per-property hints. - let rowSchema = null; - try { - const formSpec = await readYaml(dir, 'form.yaml'); - if (formSpec && formSpec.schema) { - rowSchema = formSpec.schema; + // Row schema: prefer the injected #table-context.rowSchema, else read + // /.zddc.d/form.yaml (then legacy root). Best-effort — a table + // with no row schema still renders with plain-text cells. + let rowSchema = injected.rowSchema || null; + if (!rowSchema) { + try { + const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']); + if (formSpec && formSpec.schema) { + rowSchema = formSpec.schema; + } + } catch (_) { + // form.yaml missing or unreadable; carry on without it. } - } catch (_) { - // form.yaml missing or unreadable; carry on without it. } // Rows are every *.yaml in EXCEPT the spec @@ -3825,6 +3837,22 @@ body.is-elevated::after { return window.jsyaml.load(text); } + // readYamlFirst tries each relPath in order, returning the first that + // resolves + parses. Used to read a spec from the supporting-files + // reserve (.zddc.d/) with a fallback to the legacy directory root. + async function readYamlFirst(dir, relPaths) { + let lastErr = null; + for (var i = 0; i < relPaths.length; i++) { + try { + return await readYaml(dir, relPaths[i]); + } catch (err) { + lastErr = err; + } + } + if (lastErr) throw lastErr; + return null; + } + // Walk a "/"-separated relative path under dir, returning the // FileSystemFileHandle (or HttpFileHandle) at the leaf. async function resolveFile(dir, relPath) {