feat(server): table/form specs resolve from .zddc.d/ + server-inject the table spec
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).
Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
(classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
#table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
when present (server mode) and still walks the directory for ROW files; FS-
Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
passes the injected context through. Regenerated the embedded tables.html.
go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.
Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
45af24b2b1
commit
03fa366814
5 changed files with 218 additions and 56 deletions
|
|
@ -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 <dir>/.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,28 +84,33 @@
|
|||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
||||
// <currentdir>/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
|
||||
// <dir>/.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 <dir>/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;
|
||||
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||
// <dir>/.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 readYaml(dir, 'form.yaml');
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||
// (table.yaml) and the row-edit form (form.yaml). They live
|
||||
|
|
@ -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/<name>) 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) {
|
||||
|
|
|
|||
|
|
@ -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 <dir>/.zddc.d/form.yaml
|
||||
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
|
||||
// <dir>/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
|
||||
|
|
|
|||
|
|
@ -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 (<dir>/table.yaml) or in
|
||||
// the supporting-files reserve (<dir>/.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:
|
||||
// <project>/<peer>/<party>/<file> — per-party register specs
|
||||
|
|
@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
|||
|
||||
specAbs := filepath.Join(dirAbs, "table.yaml")
|
||||
|
||||
// Presence-based discovery: <dir>/table.yaml on disk.
|
||||
if fileExists(specAbs) {
|
||||
// Presence-based discovery: the spec in the supporting-files reserve
|
||||
// (<dir>/.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 <dir>/.zddc.d/<name>, then the legacy <dir>/<name>,
|
||||
// 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
|
||||
// <dir>/table.yaml and <dir>/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": <parsed table.yaml>, "rowSchema": <parsed form.yaml .schema> }
|
||||
//
|
||||
// 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(`<script id="table-context" type="application/json">{}</script>`)
|
||||
if !bytesContains(template, needle) {
|
||||
return nil, errBundle("#table-context placeholder not found in template")
|
||||
}
|
||||
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
|
||||
replacement = append(replacement, []byte(`</script>`)...)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, `<table id="table-root"`) {
|
||||
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
|
||||
}
|
||||
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
||||
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, `<script id="table-context" type="application/json">{}</script>`) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1534,7 +1534,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-04 15:19:25 · 45af24b-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -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 <dir>/.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,28 +3753,33 @@ body.is-elevated::after {
|
|||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
||||
// <currentdir>/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
|
||||
// <dir>/.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 <dir>/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;
|
||||
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||
// <dir>/.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 readYaml(dir, 'form.yaml');
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||
// (table.yaml) and the row-edit form (form.yaml). They live
|
||||
|
|
@ -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/<name>) 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue