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), "", "<\\/"))
+ needle := []byte(``)
+ 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 {